diff --git a/.agents/skills/benchmarking-engine-builds/SKILL.md b/.agents/skills/benchmarking-engine-builds/SKILL.md new file mode 100644 index 000000000..072e8e1b8 --- /dev/null +++ b/.agents/skills/benchmarking-engine-builds/SKILL.md @@ -0,0 +1,86 @@ +--- +name: benchmarking-engine-builds +description: Use when measuring or profiling how fast libtmux.experimental engines build tmux workspaces — comparing classic vs subprocess/control_mode/imsg/mock/pipelined, chasing a build-latency regression, reading percentile grids, or finding where a control-mode build spends its time (cProfile). Runs scripts/bench_engines.py hermetically on throwaway sockets. +--- + +# Benchmarking engine builds + +## Overview + +`scripts/bench_engines.py` times how long each experimental engine takes to +build a tmux session structure (`W` windows × `P` panes-per-window), sweeping +shapes × engines × wait-modes and reporting min/avg/median/p90/p95/p99/max. + +**Hermetic and safe to run beside a live tmux session:** every server gets its +own socket under a throwaway `mkdtemp` dir, `TMUX` is unset before libtmux is +imported, and an `atexit` hook kills every spawned server. The default tmux +server is never contacted. + +It is a PEP 723 script — **always launch it with `uv run`**, never `python`, or +its inline deps (`rich`, `typer`, editable `libtmux`) won't resolve. + +## When to use + +- Comparing engine build cost (which engine is fastest for a given shape). +- Checking whether a change to the ops/plan/engine layer moved build latency. +- Reading percentile spread (is p99 blowing out?) rather than a single number. +- Locating the hot path inside one engine's build (`profile` → cProfile cumtime). + +## Quick reference + +Run from the repo root. + +| Command | What it does | +|---|---| +| `uv run scripts/bench_engines.py run` | full engine grid (the clean signal) | +| `uv run scripts/bench_engines.py matrix --shapes 1x4,3x3,5x4` | 4-axis factorial: which choice drives build cost | +| `uv run scripts/bench_engines.py concurrency --transport control_mode --k 4` | K builds sync-serial vs async-`gather` | +| `uv run scripts/bench_engines.py contract` | mock-parity ops-language check only (for CI) | +| `uv run scripts/bench_engines.py profile --engine control_mode --shape 8x4` | cProfile one engine, print slowest by cumtime | +| `uv run scripts/bench_engines.py cell control_mode 8x4` | one isolated build (for wrapping in hyperfine) | + +`run` flags: `--shapes 1x1,1x4,3x3,5x4,8x4`, `--engines classic,subprocess,control_mode,imsg,mock,pipelined`, +`--wait` (ALSO measure with shell-readiness wait), `--runs 20`, `--warmup 3`, +`--json-out grid.json`. Shape is `windows x panes-per-window`. + +`matrix` sweeps five expression layers (`imperative`, `plan-seq`, `plan-fold`, +`ws-seq`, `ws-fold`) × transport {subprocess, control_mode} × mode {sync, async} +against a `classic` reference. `mock` is the offline correctness **oracle**, not +a results row: `matrix --check` (default on) and `contract` assert every layer × +mode renders identical tmux argv to it, so the benchmark doubles as an +ops-language contract test. + +Engines: `classic` (Server/Session/Window/Pane API) · `subprocess` (one fork +per op) · `control_mode` (one persistent `tmux -C`) · `imsg` (AF_UNIX one-shot) · +`mock` (offline, in-memory Python floor) · `pipelined` (prototype: batch +independent creates via `run_batch`). + +## Reading the results + +- **`control_mode` is the fastest shipped engine** (~21× classic at 32 panes) + because it avoids a per-op `tmux` fork. `pipelined` edges it (~1.4×) by + batching independent creates into ~3 round-trips. +- **Builds are tmux-server-bound, not round-trip-bound** — one shell fork per + pane dominates, so cutting round-trips helps less than the count implies. + `mock` (~1–2 ms) is the pure-Python floor: the plan/compile layer is + negligible; the time is tmux. +- **`profile` shows ~68% in `select.epoll.poll`** inside `_read_blocks`: each + created id is read back before the next op targets it. Latency-bound. + +## Common mistakes + +- Running with `python` instead of `uv run` — PEP 723 deps don't resolve. +- **Comparing `--wait` against no-wait across engines.** Shell startup + (~0.8–2.1 s) dwarfs a fast build, so the ~20× engine win collapses to ~1.5× + once both sides wait. A classic-that-waits vs a builder-that-doesn't is + apples-to-oranges (this is the bogus "~79×" trap). Compare like with like. +- Trusting hyperfine whole-process wall time over the in-process grid — Python + startup + import dwarfs a 3 ms build and understates the builder. The + in-process `run` grid is the clean signal. +- Expecting `mock` under `--wait`: it has no real panes and is skipped. + +## Results & reproduction + +Committed results live in `scripts/bench-results/`: `RESULTS.md` (narrative + +tables), `grid.json` (no-wait grid), `wait.json` (wait comparison). Regenerate +the raw JSON with `--json-out`. diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 000000000..2b7a412b8 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/CHANGES b/CHANGES index eb0ac5620..969f1486d 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,33 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### What's new + +#### Experimental operations and engines (#690) + +Operations describe tmux commands as data. Each renders its argv against a tmux +version (dropping flags an older tmux cannot accept), adapts raw output into a +typed result, and serializes to and from plain dicts -- all without a running +tmux server. The set spans the read seam (``list-*``, ``has-session``, +``display-message``, ``show-options``, ``show-buffer``) and the +mutating/creating surface for panes, windows, the server, options, environment, +hooks, and paste buffers. A registry-generated catalog on the {ref}`experimental` +page always matches the code. + +Engines run those operations behind one protocol, so the same operation returns +the same typed result whether it goes through a subprocess (the classic path +that reproduces today's libtmux behavior), an in-memory simulator for tests and +dry runs, a persistent ``tmux -C`` control connection, an async transport, or +tmux's native binary peer protocol. Results never raise on construction; +raising is opt-in via ``raise_for_status()``, and how a failed result is handled +is each engine's policy. + +A {class}`~libtmux.experimental.ops.plan.LazyPlan` records operations and yields +forward references so a later operation can target an object that does not exist +yet, resolved against captured ids at execution time. How a plan becomes tmux +dispatches is a pluggable {class}`~libtmux.experimental.ops.planner.Planner` +(sequential, ``;``-folding, or ``{marked}``-folding), so dispatch strategies can +be A/B tested against the same plan with identical results. ### Documentation #### Cleaner `from_env` examples (#719) @@ -57,6 +84,60 @@ environment-setup plumbing, so each example leads with the constructor call it demonstrates instead of the socket-path and `$TMUX` boilerplate needed to run it. +#### Declarative workspace builds fold to a few tmux calls (#690) + +A {class}`~libtmux.experimental.workspace.ir.Workspace` declares a session as a +tree of windows and panes and lowers to a Core +{class}`~libtmux.experimental.ops.plan.LazyPlan`, so a tmuxp-style spec can be +analyzed, inspected, and built over any engine. +{meth}`~libtmux.experimental.workspace.ir.Workspace.build` and its async twin +{meth}`~libtmux.experimental.workspace.ir.Workspace.abuild` fold the build's +dispatches by default: a multi-pane window collapses from one tmux call per +operation into a handful of ``;``-chained and ``{marked}`` dispatches, so a +session renders in a few round-trips instead of dozens. + +The resulting {class}`~libtmux.experimental.ops.plan.PlanResult` is identical to +an unfolded build -- only the dispatch count changes -- because host-side steps +(per-command sleeps, the ``wait_pane`` anti-race, ``before_script``) stay hard +fold boundaries that a fold never crosses. Pass a +{class}`~libtmux.experimental.ops.planner.SequentialPlanner` to ``build`` for one +legible tmux call per operation when debugging. + +#### Floating panes on tmux 3.7 (#690) + +On tmux 3.7, the operations create floating panes -- overlays with an absolute +size, position, and optional zoom. A ``new-pane`` operation, ``new_pane()`` on +the pane wrappers, and a curated MCP tool each open one, and a +{class}`~libtmux.experimental.workspace.ir.Workspace` can declare floating panes +on a window, including a pane that overlays a different window. + +#### Query and command live panes with `panes()` (#690) + +{func}`~libtmux.experimental.query.panes` is a lazy, chainable query over the +panes a running server has: ``filter``, ``order_by``, ``limit``, and ``map`` +compose and read nothing until a terminal call. The same query commands what it +selects -- ``commands()`` attaches per-pane actions (send keys, resize, select, +respawn, clear history, kill) that run as one folded tmux dispatch. A query +resolves against a live engine or a plain list of pane snapshots, so the same +code runs offline in tests. + +#### Drive tmux from an MCP server (#690) + +An optional Model Context Protocol server exposes tmux as tools an AI agent can +call, installed with the ``libtmux[mcp]`` extra and launched as +``libtmux-engine-mcp``. It offers a curated vocabulary of intuitive verbs +(``send_input``, ``wait_for_output``, ``split_pane``, ``capture_pane``, +``new_pane``, and the session, window, and pane lifecycle), a tool for every +individual operation, and plan tools that preview or build a whole workspace in +one call. + +The server is caller-aware: because a control-mode agent shares the server with +the panes it drives, it knows which pane launched it and refuses to kill or +respawn its own pane, window, or session. A safety level (``LIBTMUX_SAFETY``) +keeps mutating and destructive tools hidden until an operator opts in, and a +needle-free ``wait_for_output`` reports when a pane goes quiet after a command -- +no sentinel string -- and whether its process exited. + ## libtmux 0.62.0 (2026-07-12) libtmux 0.62.0 teaches libtmux objects to locate themselves and to resolve diff --git a/docs/_ext/tmuxop.py b/docs/_ext/tmuxop.py new file mode 100644 index 000000000..550c0bbd8 --- /dev/null +++ b/docs/_ext/tmuxop.py @@ -0,0 +1,120 @@ +"""Sphinx directive that renders the experimental operation catalog. + +``.. tmuxop-catalog::`` (or the MyST fenced form) walks +:func:`libtmux.experimental.ops.catalog` and emits a table of operations with +their scope, safety tier, result type, minimum tmux version, and summary. The +operation registry is the single source of truth, so the rendered reference +cannot drift from the code. + +Options +------- +``:scope:`` / ``:safety:`` + Filter to one scope (``pane``/``window``/``session``/``server``/``client``) + or safety tier (``readonly``/``mutating``/``destructive``). +``:primitive-only:`` + Show only operations that wrap a single tmux command. + +This is the in-repo renderer; a full gp-sphinx ``tmuxop`` domain (cross-reference +roles + an operations index) can later replace it under the same directive name. +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx.util import logging +from sphinx.util.docutils import SphinxDirective + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + +_HEADERS = ("Operation", "Command", "Scope", "Safety", "Result", "Min tmux", "Summary") + + +def _row(cells: Sequence[str]) -> nodes.row: + """Build a docutils table row from string cells.""" + row = nodes.row() + for cell in cells: + entry = nodes.entry() + entry += nodes.paragraph(text=cell) + row += entry + return row + + +def _table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> nodes.table: + """Build a simple docutils table.""" + table = nodes.table() + tgroup = nodes.tgroup(cols=len(headers)) + table += tgroup + for _ in headers: + tgroup += nodes.colspec(colwidth=1) + thead = nodes.thead() + thead += _row(headers) + tgroup += thead + tbody = nodes.tbody() + for row in rows: + tbody += _row(row) + tgroup += tbody + return table + + +class TmuxopCatalogDirective(SphinxDirective): + """Render the operation catalog as a table.""" + + has_content = False + option_spec: t.ClassVar[dict[str, t.Any]] = { + "scope": directives.unchanged, + "safety": directives.unchanged, + "primitive-only": directives.flag, + } + + def run(self) -> list[nodes.Node]: + """Build the catalog table from the operation registry.""" + from libtmux.experimental.ops import catalog + + entries = catalog() + scope = self.options.get("scope") + safety = self.options.get("safety") + if scope: + entries = [entry for entry in entries if entry.scope == scope] + if safety: + entries = [entry for entry in entries if entry.safety == safety] + if "primitive-only" in self.options: + entries = [entry for entry in entries if entry.primitive] + + if not entries: + logger.warning( + "tmuxop-catalog: no operations matched the given filters", + location=self.get_location(), + ) + return [] + + rows = [ + ( + entry.kind, + entry.command, + entry.scope, + entry.safety, + entry.result_type, + entry.min_version or "-", + entry.summary, + ) + for entry in entries + ] + return [_table(_HEADERS, rows)] + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the directive.""" + app.add_directive("tmuxop-catalog", TmuxopCatalogDirective) + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/conf.py b/docs/conf.py index 379d0b3c6..5efc0c9a9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,7 @@ project_src = project_root / "src" sys.path.insert(0, str(project_src)) +sys.path.insert(0, str(cwd / "_ext")) # package data about: dict[str, str] = {} @@ -34,6 +35,7 @@ "sphinx_autodoc_api_style", "sphinx_autodoc_pytest_fixtures", "sphinx.ext.todo", + "tmuxop", ], intersphinx_mapping={ "python": ("https://docs.python.org/", None), diff --git a/docs/experimental.md b/docs/experimental.md new file mode 100644 index 000000000..d3b112412 --- /dev/null +++ b/docs/experimental.md @@ -0,0 +1,175 @@ +(experimental)= + +# Experimental: operations & engines + +```{warning} +Everything under {mod}`libtmux.experimental` is **not** covered by the +versioning policy and may change or be removed between any two releases. +``` + +`libtmux.experimental` hosts an inert, typed *operation* substrate and the +*engines* that execute it. An operation describes a tmux command (it renders +argv, carries its result type and metadata, and serializes) without dispatching; +an engine runs operations and returns typed results. The same operation returns +the same typed result whether executed by a subprocess, an in-memory simulator, +a persistent `tmux -C` control connection, or an async transport. + +See ``tmux-python/libtmux`` issue 689 for the operationalization plan. + +## Running an operation + +An operation is a value; ``run`` (or ``arun`` for async) hands it to an engine +and returns the engine's typed result. Results never raise on construction -- +inspect ``ok``/``status``, or opt into raising with ``raise_for_status()``: + +```python +>>> from libtmux.experimental.ops import HasSession, run +>>> from libtmux.experimental.ops._types import SessionId +>>> from libtmux.experimental.engines import MockEngine +>>> result = run(HasSession(target=SessionId("$0")), MockEngine()) +>>> result.ok +True +>>> result.raise_for_status() is result +True +``` + +How a *failed* result is treated is the engine's policy: the classic subprocess +path raises in its wrapper to match today's libtmux behavior, while the newer +engines hand the result back and let the caller decide. + +## Choosing an engine + +Every engine satisfies the same ``TmuxEngine`` (or ``AsyncTmuxEngine``) +protocol, so swapping engines never changes an operation or its result type -- +only *how* and *where* the command runs. + +| Engine | Transport | Use it for | +| --- | --- | --- | +| ``SubprocessEngine`` | one ``tmux`` process per command | the classic path; reproduces today's libtmux behavior | +| ``MockEngine`` | in-memory, no tmux | tests and dry runs (deterministic, fabricated output) | +| ``ControlModeEngine`` | a persistent ``tmux -C`` connection | many commands over one long-lived session | +| ``ImsgEngine`` | tmux's native binary peer protocol | an opt-in easter egg | + +Each has an ``Async*`` counterpart (``AsyncSubprocessEngine``, +``AsyncMockEngine``, ``AsyncControlModeEngine``) behind ``AsyncTmuxEngine``. +Construct one directly, bind it to a live server with +``SubprocessEngine.for_server(server)``, or select one by name from the engine +registry: + +```python +>>> from libtmux.experimental.engines import available_engines, create_engine +>>> from libtmux.experimental.ops import HasSession, run +>>> from libtmux.experimental.ops._types import SessionId +>>> available_engines() +('control_mode', 'imsg', 'mock', 'subprocess') +>>> engine = create_engine("mock") +>>> run(HasSession(target=SessionId("$0")), engine).status +'complete' +``` + +## Lazy plans and planners + +A {class}`~libtmux.experimental.ops.plan.LazyPlan` records operations without +running them, returning a forward *slot reference* for each created object so a +later operation can target something that does not exist yet. ``execute`` +(or ``aexecute``) resolves those references against captured ids as it goes: + +```python +>>> from libtmux.experimental.ops import LazyPlan, SplitWindow, SendKeys +>>> from libtmux.experimental.ops._types import WindowId +>>> from libtmux.experimental.engines import MockEngine +>>> plan = LazyPlan() +>>> pane = plan.add(SplitWindow(target=WindowId("@1"))) +>>> _ = plan.add(SendKeys(target=pane, keys="echo hi", enter=True)) +>>> outcome = plan.execute(MockEngine()) +>>> outcome.ok +True +>>> [r.status for r in outcome.results] +['complete', 'complete'] +``` + +Operations also compose with ``>>`` into a chain, which a plan can run as one +dispatch when the members are chainable. + +*How* a plan turns into dispatches is a pluggable +{class}`~libtmux.experimental.ops.planner.Planner`, so strategies can be A/B +tested against the same plan: + +- ``SequentialPlanner`` -- one dispatch per operation (the default). +- ``FoldingPlanner`` -- folds adjacent chainable operations into a single + ``;``-separated dispatch. +- ``MarkedPlanner`` -- folds a "create then decorate the new pane" run into one + dispatch using tmux's ``{marked}`` register. + +Every planner produces the same per-operation result; they differ only in how +many times tmux is invoked: + +```python +>>> from libtmux.experimental.ops import LazyPlan, SplitWindow, SendKeys, FoldingPlanner +>>> from libtmux.experimental.ops._types import WindowId +>>> from libtmux.experimental.engines import MockEngine +>>> plan = LazyPlan() +>>> pane = plan.add(SplitWindow(target=WindowId("@1"))) +>>> _ = plan.add(SendKeys(target=pane, keys="echo hi", enter=True)) +>>> plan.execute(MockEngine(), planner=FoldingPlanner()).ok +True +``` + +## Building fluently with `plan()` + +{func}`~libtmux.experimental.fluent.plan` is a fluent builder over a plan: you +name a session, walk down to a pane, and record what each pane runs, without +threading the new ids through yourself. Nothing touches tmux until +{meth}`~libtmux.experimental.fluent.PlanBuilder.run`, which folds the whole +description into a few dispatches (its async twin is ``arun``): + +```python +>>> from libtmux.experimental.fluent import plan +>>> from libtmux.experimental.engines import MockEngine +>>> p = plan() +>>> pane = p.new_session("dev").window().pane() +>>> _ = pane.do(lambda c: c.send_keys("vim")).split().do(lambda c: c.send_keys("htop")) +>>> p.run(MockEngine()).ok +True +``` + +``.split()`` makes a new pane that does not exist yet, so it comes back as a +*forward* handle: you keep building on it, but reading its id is a static type +error (the concrete {class}`~libtmux.experimental.query.PaneRef` has +``.pane_id``; the {class}`~libtmux.experimental.query.ForwardPaneRef` does not), +resolved against the captured id only when the plan runs. + +``run()`` folds by default and breaks the fold only at a true blocker -- a +created id a later op needs, or a host pause recorded by ``sleep()``/``wait()``. +{meth}`~libtmux.experimental.fluent.PlanBuilder.find_or_create_session` makes the +create conditional, so re-running a build reuses a live session instead of +duplicating it: + +```python +>>> from libtmux.experimental.fluent import plan +>>> from libtmux.experimental.engines import MockEngine +>>> p = plan() +>>> _ = p.find_or_create_session("dev").window().pane() +>>> p.run(MockEngine()).ok +True +``` + +## Operation catalog + +The catalog below is generated from the operation registry, so it always matches +the code. + +```{tmuxop-catalog} +``` + +### Read-only operations + +```{tmuxop-catalog} +:safety: readonly +``` + +### Destructive operations + +```{tmuxop-catalog} +:safety: destructive +``` diff --git a/docs/index.md b/docs/index.md index 683c17ebc..f6f5c4f48 100644 --- a/docs/index.md +++ b/docs/index.md @@ -125,6 +125,7 @@ api/index api/testing/index internals/index project/index +experimental history migration glossary diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index ba9641108..df0aa294f 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -118,13 +118,18 @@ command that never finishes can't hang your script forever. The `poll_interval` the latency/work trade in one knob: poll faster to react sooner, slower to spare tmux the round-trips. +> **Note:** This polls with `capture_pane` + `sleep` — correct for the +> synchronous library. If you drive tmux through the libtmux MCP server, prefer +> the event-backed `wait_for_output` tool instead: it folds live `%output` and +> returns when the pane settles, with no polling. + ```python >>> import time >>> monitor_window = session.new_window(window_name='monitor', attach=False) >>> monitor_pane = monitor_window.active_pane ->>> def wait_for_output(pane, text, timeout=5.0, poll_interval=0.1): +>>> def wait_for_text(pane, text, timeout=5.0, poll_interval=0.1): ... """Wait for specific text to appear in pane output.""" ... start = time.time() ... while time.time() - start < timeout: @@ -135,7 +140,7 @@ the round-trips. ... return False >>> monitor_pane.send_keys('sleep 0.2; echo "READY"') ->>> wait_for_output(monitor_pane, 'READY', timeout=2.0) +>>> wait_for_text(monitor_pane, 'READY', timeout=2.0) True >>> # Clean up diff --git a/fastmcp.json b/fastmcp.json new file mode 100644 index 000000000..31bbfab36 --- /dev/null +++ b/fastmcp.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "type": "filesystem", + "path": "src/libtmux/experimental/mcp/__init__.py", + "entrypoint": "default_async_server" + }, + "deployment": { + "transport": "stdio" + } +} diff --git a/pyproject.toml b/pyproject.toml index efa9208a4..90ba377af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,12 @@ dev = [ "pytest-mock", "pytest-watcher", "pytest-xdist", + # MCP adapter — the optional `mcp` extra, included here so the gate + # type-checks (mypy) and exercises the fastmcp adapter + its tests + # instead of silently skipping them. + "fastmcp", + # Scripts (scripts/mcp_swap.py dev tool) + "tomlkit", # Coverage "codecov", "coverage", @@ -72,6 +78,7 @@ dev = [ # Lint "ruff", "mypy", + "ty", ] docs = [ @@ -87,6 +94,10 @@ testing = [ "pytest-rerunfailures", "pytest-mock", "pytest-watcher", + # MCP adapter (optional `mcp` extra) so the adapter tests run here too + "fastmcp", + # Scripts (scripts/mcp_swap.py dev tool) + "tomlkit", ] coverage =[ "codecov", @@ -102,6 +113,16 @@ lint = [ [project.entry-points.pytest11] libtmux = "libtmux.pytest_plugin" +[project.scripts] +# Experimental typed-ops MCP server (stdio). Requires the `mcp` extra; +# `main` prints an install hint and exits non-zero when fastmcp is absent. +libtmux-engine-mcp = "libtmux.experimental.mcp:main" + +[project.optional-dependencies] +mcp = [ + "fastmcp>=3.4.2", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -140,6 +161,74 @@ files = [ ] +[tool.ty.environment] +python-version = "3.10" + +[tool.ty.src] +include = ["src", "tests"] + +[tool.ty.rules] +# Private _pytest APIs (e.g. RaisesContext) are not publicly exported; +# both mypy and ty flag them. ty categorizes these as unresolved-import +# rather than attr-defined. +# https://github.com/astral-sh/ty/issues/1276 +unresolved-import = "ignore" +# ty resolves cmd() as a union type including a (Any, Any, /) -> tmux_cmd +# variant from CmdProtocol, causing false positives on *args methods. +# ty does not yet fully support argument unpacking (*args/**kwargs). +# https://github.com/astral-sh/ty/issues/404 +too-many-positional-arguments = "ignore" +# Same root cause as too-many-positional-arguments: ty cannot verify +# required params are present when calling with **kwargs unpacking. +# https://github.com/astral-sh/ty/issues/785 +missing-argument = "ignore" +# ty falls back to object.__init__ for union return types and +# dataclass-transform decorators, flagging all kwargs as unknown. +# https://github.com/astral-sh/ty/issues/2369 +unknown-argument = "ignore" +# Tests use monkeypatch.setattr(libtmux.common, ...) without explicit +# submodule import. The submodule is always available via __init__.py +# re-exports but ty cannot detect implicit registration. +# https://github.com/astral-sh/ty/issues/133 +possibly-missing-submodule = "ignore" +# Vendored version.py uses tuple comparison with mixed-type elements +# (int, str, InfinityType) for PEP 440 version ordering. ty cannot +# resolve element-wise comparison operators across union-typed tuples. +# https://github.com/astral-sh/ty/issues/1202 +unsupported-operator = "ignore" +# ty cannot verify argument types through **kwargs unpacking and +# narrows LiteralString to str differently than mypy. 20 false +# positives, mostly from **call_kwargs and **filter_expr patterns. +# https://github.com/astral-sh/ty/issues/785 +invalid-argument-type = "ignore" +# ty doesn't narrow through dict value iteration (item.split() in +# options.py) or union access patterns (Pane | None). 5 false +# positives where mypy correctly narrows the type. +unresolved-attribute = "ignore" +# ty can't see through isinstance narrowing for TypeVars (subscript +# assignment on _V in options.py) and IO[str] | None unions +# (control_mode.py, already suppressed for mypy). 4 false positives. +invalid-assignment = "ignore" +# Pane, Session, Window inherit from Obj which defines defaulted fields; +# subclasses add required server: Server. Python dataclass inheritance +# handles this at runtime but ty's analysis doesn't account for it. +dataclass-field-order = "ignore" +# re.search(rhs, data) in query_list.py where isinstance narrows both +# to str | bytes, but ty can't match the overload (str, str) | (bytes, +# Buffer) against (str | bytes, str | bytes). 2 false positives. +no-matching-overload = "ignore" +# options.py returns dict subscript typed as object where str | int | +# None expected; test_session.py returns MockTmuxCmd where tmux_cmd +# expected (already suppressed for mypy). 2 false positives. +invalid-return-type = "ignore" +# query_list.py b[key] where b is typed as object from a narrowing +# path ty can't follow (same context as unresolved-attribute). +not-subscriptable = "ignore" +# query_list.py filter_(k) where filter_ is a union including +# T@QueryList & Top[(...) -> object] — ty's intersection type +# resolution produces an uncallable Top type. +call-top-callable = "ignore" + [tool.coverage.run] branch = true parallel = true @@ -236,7 +325,10 @@ addopts = [ "--showlocals", "--doctest-docutils-modules", "-p no:doctest", - "--reruns=2" + "--reruns=2", + # Built HTML lives under docs/_build and `docs` is a testpath; never + # collect generated artifacts (their relative directives fail to parse). + "--ignore=docs/_build", ] doctest_optionflags = [ "ELLIPSIS", diff --git a/scripts/bench-results/RESULTS.md b/scripts/bench-results/RESULTS.md new file mode 100644 index 000000000..f4ea030f1 --- /dev/null +++ b/scripts/bench-results/RESULTS.md @@ -0,0 +1,137 @@ +# libtmux engine build-benchmark — results + +Produced by `scripts/bench_engines.py` (a hermetic PEP 723 grid runner) plus a +one-off hyperfine end-to-end run. All builds are isolated: per-run sockets under +a throwaway dir, `TMUX` unset, servers killed on exit — the default tmux server +is never contacted. Reproduce with: + +```console +$ uv run scripts/bench_engines.py run +$ uv run scripts/bench_engines.py run --engines classic,control_mode,pipelined --wait +$ uv run scripts/bench_engines.py profile --engine control_mode --shape 8x4 +``` + +Raw data: `grid.json` (no-wait grid), `wait.json` (wait comparison). + +## Engine grid — in-process build, median ms (xN vs classic), 20 runs + +Shape = `windows x panes-per-window`. Structural builds (no shell-readiness wait). + +| engine | 1x1 | 1x4 | 3x3 | 5x4 | 8x4 | +|---|--:|--:|--:|--:|--:| +| classic (Server/Session/Window/Pane) | 22.0 | 169.4 | 452.5 | 1442.2 | 3497.2 | +| builder / subprocess | 23.0 | 42.8 | 86.9 | 246.7 | 428.8 (8x) | +| builder / imsg | 20.6 | 31.1 | 62.6 | 153.4 | 262.0 (13x) | +| builder / control_mode | 2.5 | 9.4 | 26.5 | 103.3 | 166.7 (21x) | +| **pipelined (prototype)** | **1.4** | **7.9** | **20.2** | **65.3** | **115.7 (30x)** | +| mock (offline, in-memory) | 0.1 | 0.1 | 0.3 | 1.3 | 1.5 | + +Full percentiles at 8x4 (ms): + +| engine | min | avg | median | p90 | p95 | p99 | max | +|---|--:|--:|--:|--:|--:|--:|--:| +| classic | 2192 | 3404 | 3497 | 3931 | 4077 | 4432 | 4432 | +| subprocess | 358 | 426 | 429 | 479 | 481 | 487 | 487 | +| imsg | 222 | 283 | 262 | 342 | 421 | 455 | 455 | +| control_mode | 118 | 180 | 167 | 215 | 216 | 398 | 398 | +| pipelined | 97 | 123 | 116 | 156 | 165 | 194 | 194 | +| mock | 1 | 2 | 2 | 2 | 2 | 2 | 2 | + +Reads: + +- **control_mode** (one persistent `tmux -C`, no per-op fork) is the fastest + shipped engine: 21x classic at 32 panes. +- **imsg** (AF_UNIX one-shot per call) sits between subprocess and control_mode; + its per-call handshake makes tiny builds no faster than classic. +- **pipelined** (prototype: batch independent creates into ~3 `run_batch` + round-trips instead of ~34) is fastest overall, ~1.4x over control_mode. Not + the 11x the round-trip count implies, because the build is **tmux-server-bound** + (one shell fork per pane), not round-trip-bound. `mock` (offline, 1.5 ms) + is the Python floor: the plan/compile layer is negligible; the time is tmux. + +## With vs without shell-readiness wait + +`wait.json`, 10 runs. "wait" polls each pane until its shell has drawn a prompt. + +| shape | engine | nowait median | wait median | wait penalty | speedup vs classic | +|---|---|--:|--:|--:|--:| +| 1x4 | classic | 217.6 | 1134.5 | 5.2x | 1.0x | +| 1x4 | control_mode | 9.4 | 865.2 | 92x | 23.1x -> 1.3x | +| 1x4 | pipelined | 9.0 | 1004.3 | 112x | 24.3x -> 1.1x | +| 5x4 | classic | 1365.6 | 3252.7 | 2.4x | 1.0x | +| 5x4 | control_mode | 73.1 | 2194.5 | 30x | 18.7x -> 1.5x | +| 5x4 | pipelined | 66.6 | 2123.3 | 32x | 20.5x -> 1.5x | + +**The engine win only exists when nobody waits for shells.** Shell startup +(~0.8-2.1 s) dominates a fast build (30-112x penalty) but barely moves the slow +classic path (2-5x), so the ~20x engine advantage collapses to ~1.5x once both +sides wait. Comparing a classic-that-waits against a builder-that-doesn't is +apples-to-oranges (this is why an earlier ad-hoc run reported ~79x). + +## Profile — where a control_mode build spends time (32 panes x5) + +~200 ms/build; **68% is `select.epoll.poll`** in `control_mode._read_blocks` — +one round-trip per created id (each new window/pane id read back before the next +op targets it). Round-trip-latency bound, not CPU/Python bound. + +## Whole-process wall time (hyperfine, SubprocessEngine, end-to-end) + +For reference, whole-script wall time (Python start + import + build + teardown) +on `SubprocessEngine`, 50 runs: classic simple 210 ms / large 6764 ms; builder +simple 453 ms / large 1692 ms. End-to-end on the *slowest* engine understates the +builder (startup dwarfs a 3 ms build) — the in-process grid above is the clean +signal. Prefer `control_mode` and in-process timing to measure build cost. + +## Build-cost factorial — `matrix` + +The `matrix` subcommand isolates which of four independent choices drives build +cost, sweeping them as a factorial: **async** {sync, async} × **transport** +{subprocess, control_mode} × **planner** {imperative, plan-seq, plan-fold} × +**workspace** {hand `LazyPlan`, declarative `Workspace`}. Because a declarative +workspace compiles to a lazy plan, the valid expression layers are five — +`imperative`, `plan-seq`, `plan-fold`, `ws-seq`, `ws-fold` — crossed with 2 +transports × 2 modes = 20 cells, against a `classic` reference. Reproduce: + +```console +$ uv run scripts/bench_engines.py matrix --shapes 1x4,3x3,5x4 +``` + +`mock` is absent from the table by design: it is the offline correctness +**oracle**, not a results row. `matrix --check` (default on) and the standalone +`contract` subcommand assert every layer × mode renders identical tmux argv to +mock, so the benchmark doubles as an ops-language contract test: + +```console +$ uv run scripts/bench_engines.py contract +``` + +Illustrative 1×4 snapshot (small run; absolute ms are machine-dependent — the +ratios are the portable signal): + +| layer × transport × mode | median ms | vs classic | +|---|--:|--:| +| classic (reference) | 168.7 | 1.0x | +| ws-fold · control_mode · sync | 7.8 | 21.5x | +| plan-fold · control_mode · sync | 6.3 | 26.8x | +| imperative · control_mode · async | 7.0 | 24.0x | +| ws-fold · subprocess · sync | 30.7 | 5.5x | + +Reads: transport dominates (control_mode ~4-5× subprocess); planner choice +(imperative → plan-seq → plan-fold) and the declarative-workspace compile move +build cost only marginally — the plan/compile layer is cheap, the time is tmux. +Single-build async ≈ sync (a linear build serializes either way). + +## Concurrency — `concurrency` + +Async's real lever is not single-build latency but overlap. `concurrency` +builds K independent sessions sync-serial vs `asyncio.gather` over one +connection: + +```console +$ uv run scripts/bench_engines.py concurrency --transport control_mode --k 4 +``` + +Over one `control_mode` connection, async-gather runs ~2.4× the sync-serial +wall time at K=4 — the persistent connection multiplexes the K builds' +round-trips instead of paying them end-to-end. This is the win the single-build +matrix structurally cannot show. diff --git a/scripts/bench-results/grid.json b/scripts/bench-results/grid.json new file mode 100644 index 000000000..cfb79abbf --- /dev/null +++ b/scripts/bench-results/grid.json @@ -0,0 +1,1082 @@ +[ + { + "engine": "classic", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 25.435490999370813, + 21.44604700151831, + 19.487711018882692, + 22.916321991942823, + 26.080587063916028, + 20.377128035761416, + 19.85792105551809, + 21.783681004308164, + 27.05869299825281, + 22.205271059647202, + 20.67994710523635, + 26.499966043047607, + 24.94822407606989, + 30.270048999227583, + 29.39265000168234, + 21.638029953464866, + 20.480802981182933, + 20.635419990867376, + 26.2573619838804, + 19.851638935506344 + ], + "n_ms": 20.0, + "min_ms": 19.487711018882692, + "avg_ms": 23.365147114964202, + "median_ms": 21.994476031977683, + "p90_ms": 27.05869299825281, + "p95_ms": 29.39265000168234, + "p99_ms": 30.270048999227583, + "max_ms": 30.270048999227583 + }, + { + "engine": "subprocess", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 22.950448095798492, + 23.71105900965631, + 27.512941975146532, + 24.13841593079269, + 22.9648700915277, + 23.848011973313987, + 25.90626000892371, + 20.328919985331595, + 21.03408903349191, + 21.60188800189644, + 26.37235203292221, + 22.638716967776418, + 20.477519021369517, + 19.63279501069337, + 28.77276297658682, + 25.713130016811192, + 20.554474089294672, + 20.25220892392099, + 28.945287107490003, + 22.18223095405847 + ], + "n_ms": 20.0, + "min_ms": 19.63279501069337, + "avg_ms": 23.47691906034015, + "median_ms": 22.957659093663096, + "p90_ms": 27.512941975146532, + "p95_ms": 28.77276297658682, + "p99_ms": 28.945287107490003, + "max_ms": 28.945287107490003 + }, + { + "engine": "control_mode", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 2.3771480191498995, + 2.6499099330976605, + 2.8321900172159076, + 2.5614179903641343, + 2.9272950487211347, + 2.5065389927476645, + 2.832969999872148, + 2.916119061410427, + 2.130561973899603, + 2.0278169540688396, + 2.620737999677658, + 2.21067201346159, + 1.859560958109796, + 2.2114990279078484, + 2.182059921324253, + 2.0884659606963396, + 2.447605016641319, + 4.296073107980192, + 4.0978980250656605, + 3.2866770634427667 + ], + "n_ms": 20.0, + "min_ms": 1.859560958109796, + "avg_ms": 2.653160854242742, + "median_ms": 2.5339784915558994, + "p90_ms": 3.2866770634427667, + "p95_ms": 4.0978980250656605, + "p99_ms": 4.296073107980192, + "max_ms": 4.296073107980192 + }, + { + "engine": "imsg", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 25.242659961804748, + 23.58605689369142, + 21.349252085201442, + 22.916006040759385, + 18.253559013828635, + 18.885195022448897, + 35.018087015487254, + 37.03230095561594, + 36.96872899308801, + 36.74224903807044, + 18.353665014728904, + 18.583990982733667, + 17.208026023581624, + 24.591958965174854, + 19.304446992464364, + 18.249089014716446, + 18.050470971502364, + 22.29922788683325, + 19.827933982014656, + 17.557888058945537 + ], + "n_ms": 20.0, + "min_ms": 17.208026023581624, + "avg_ms": 23.50103964563459, + "median_ms": 20.58859303360805, + "p90_ms": 36.74224903807044, + "p95_ms": 36.96872899308801, + "p99_ms": 37.03230095561594, + "max_ms": 37.03230095561594 + }, + { + "engine": "mock", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 0.05781592335551977, + 0.05536503158509731, + 0.08927390445023775, + 0.06635801400989294, + 0.05585001781582832, + 0.05495199002325535, + 0.053521012887358665, + 0.05605991464108229, + 0.05437701474875212, + 0.052780029363930225, + 0.05164195317775011, + 0.0517780426889658, + 0.0520970206707716, + 0.05274603608995676, + 0.05097396206110716, + 0.05048594903200865, + 0.05105405580252409, + 0.051804003305733204, + 0.05062599666416645, + 0.0513358972966671 + ], + "n_ms": 20.0, + "min_ms": 0.05048594903200865, + "avg_ms": 0.05554478848353028, + "median_ms": 0.05276303272694349, + "p90_ms": 0.05781592335551977, + "p95_ms": 0.06635801400989294, + "p99_ms": 0.08927390445023775, + "max_ms": 0.08927390445023775 + }, + { + "engine": "pipelined", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 1.272339024581015, + 1.423096051439643, + 2.0246210042387247, + 2.4055520771071315, + 2.593372017145157, + 1.708880066871643, + 1.5237980987876654, + 1.258800970390439, + 1.4068959280848503, + 1.528986031189561, + 1.2518159346655011, + 1.1928770691156387, + 1.2443959712982178, + 1.61149597261101, + 1.3652080669999123, + 1.1852029711008072, + 1.2318679364398122, + 1.9605309935286641, + 2.02260899823159, + 1.3686279999092221 + ], + "n_ms": 20.0, + "min_ms": 1.1852029711008072, + "avg_ms": 1.5790486591868103, + "median_ms": 1.4149959897622466, + "p90_ms": 2.0246210042387247, + "p95_ms": 2.4055520771071315, + "p99_ms": 2.593372017145157, + "max_ms": 2.593372017145157 + }, + { + "engine": "classic", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 153.5736940568313, + 152.53363398369402, + 176.28063401207328, + 162.175236037001, + 175.2850729972124, + 169.6498990058899, + 160.512474947609, + 175.7287960499525, + 156.43915405962616, + 160.1339690387249, + 207.24134298507124, + 189.16924006771296, + 194.17230098042637, + 170.98306701518595, + 161.9495979975909, + 175.3947560209781, + 170.09779904037714, + 154.71308294218034, + 169.2105890251696, + 141.2136929575354 + ], + "n_ms": 20.0, + "min_ms": 141.2136929575354, + "avg_ms": 168.82290166104212, + "median_ms": 169.43024401552975, + "p90_ms": 189.16924006771296, + "p95_ms": 194.17230098042637, + "p99_ms": 207.24134298507124, + "max_ms": 207.24134298507124 + }, + { + "engine": "subprocess", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 38.20429800543934, + 34.88947299774736, + 56.08579399995506, + 35.82027601078153, + 36.9337199954316, + 35.59582296293229, + 46.69300094246864, + 39.19104894157499, + 37.6851549372077, + 35.862258984707296, + 30.545516056008637, + 41.76524991635233, + 50.84225791506469, + 50.91948399785906, + 55.70168199483305, + 49.489257973618805, + 43.84331707842648, + 59.19129599351436, + 52.224029088392854, + 54.89515699446201 + ], + "n_ms": 20.0, + "min_ms": 30.545516056008637, + "avg_ms": 44.318904739338905, + "median_ms": 42.804283497389406, + "p90_ms": 55.70168199483305, + "p95_ms": 56.08579399995506, + "p99_ms": 59.19129599351436, + "max_ms": 59.19129599351436 + }, + { + "engine": "control_mode", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 7.563800900243223, + 8.978422963991761, + 8.618877036496997, + 9.57214506343007, + 10.934944031760097, + 12.400830979458988, + 11.549205984920263, + 7.952383020892739, + 9.504236048087478, + 8.437798009254038, + 7.7332829823717475, + 9.446645970456302, + 9.59436094854027, + 8.322115987539291, + 9.378890972584486, + 8.756348979659379, + 9.824263979680836, + 7.038456969894469, + 10.727863991633058, + 11.08790806028992 + ], + "n_ms": 20.0, + "min_ms": 7.038456969894469, + "avg_ms": 9.37113914405927, + "median_ms": 9.412768471520394, + "p90_ms": 11.08790806028992, + "p95_ms": 11.549205984920263, + "p99_ms": 12.400830979458988, + "max_ms": 12.400830979458988 + }, + { + "engine": "imsg", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 36.68499703053385, + 34.726232988759875, + 24.83677805867046, + 26.792447897605598, + 36.66620596777648, + 28.368790983222425, + 27.89685397874564, + 37.136508035473526, + 34.588526003062725, + 31.007860088720918, + 26.524171931669116, + 33.32362789660692, + 44.18608907144517, + 39.438307052478194, + 28.73879298567772, + 27.788623003289104, + 30.046261032111943, + 31.244902056641877, + 33.68811297696084, + 28.78861501812935 + ], + "n_ms": 20.0, + "min_ms": 24.83677805867046, + "avg_ms": 32.123635202879086, + "median_ms": 31.126381072681397, + "p90_ms": 37.136508035473526, + "p95_ms": 39.438307052478194, + "p99_ms": 44.18608907144517, + "max_ms": 44.18608907144517 + }, + { + "engine": "mock", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 0.13607402797788382, + 0.23710704408586025, + 0.14775199815630913, + 0.13310997746884823, + 0.12774800416082144, + 0.12735999189317226, + 0.1251481007784605, + 0.12269604485481977, + 0.12107205111533403, + 0.11823198292404413, + 0.27573294937610626, + 0.2059279941022396, + 0.1430619740858674, + 0.1845939550548792, + 0.13147201389074326, + 0.13140100054442883, + 0.125426915474236, + 0.1242499565705657, + 0.12552295811474323, + 0.12560293544083834 + ], + "n_ms": 20.0, + "min_ms": 0.11823198292404413, + "avg_ms": 0.14846459380351007, + "median_ms": 0.12957450235262513, + "p90_ms": 0.2059279941022396, + "p95_ms": 0.23710704408586025, + "p99_ms": 0.27573294937610626, + "max_ms": 0.27573294937610626 + }, + { + "engine": "pipelined", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 6.6258920123800635, + 9.559032041579485, + 9.58340906072408, + 5.963396979495883, + 8.836780907586217, + 10.417644982226193, + 7.979689980857074, + 8.669061004184186, + 6.5402110340073705, + 7.1102980291470885, + 6.61726703401655, + 7.768513984046876, + 8.26644105836749, + 6.927274982444942, + 6.618922925554216, + 6.4139129826799035, + 7.574769086204469, + 9.094446897506714, + 8.622737019322813, + 9.986574063077569 + ], + "n_ms": 20.0, + "min_ms": 5.963396979495883, + "avg_ms": 7.958813803270459, + "median_ms": 7.874101982451975, + "p90_ms": 9.58340906072408, + "p95_ms": 9.986574063077569, + "p99_ms": 10.417644982226193, + "max_ms": 10.417644982226193 + }, + { + "engine": "classic", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 463.1088289897889, + 403.2544719520956, + 459.22533597331494, + 505.975084961392, + 438.8479490298778, + 482.3058929760009, + 413.75007992610335, + 413.2016849471256, + 444.53056796919554, + 483.2421139581129, + 468.3551120106131, + 470.5149090150371, + 416.62799497134984, + 454.917594906874, + 409.0973420534283, + 427.1308289607987, + 460.38287493865937, + 450.0137569848448, + 479.48227205779403, + 442.03562603797764 + ], + "n_ms": 20.0, + "min_ms": 403.2544719520956, + "avg_ms": 449.3000161310192, + "median_ms": 452.4656759458594, + "p90_ms": 482.3058929760009, + "p95_ms": 483.2421139581129, + "p99_ms": 505.975084961392, + "max_ms": 505.975084961392 + }, + { + "engine": "subprocess", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 96.40262997709215, + 107.8126790234819, + 102.82182600349188, + 95.2509690541774, + 93.2349389186129, + 86.750422953628, + 89.36347393319011, + 89.38358607701957, + 113.29700902570039, + 82.9845640109852, + 90.94950300641358, + 83.90699897427112, + 81.64807397406548, + 87.03590603545308, + 73.54520098306239, + 82.44245196692646, + 72.46743002906442, + 82.35985110513866, + 79.32134799193591, + 83.34585605189204 + ], + "n_ms": 20.0, + "min_ms": 72.46743002906442, + "avg_ms": 88.71623595478013, + "median_ms": 86.89316449454054, + "p90_ms": 102.82182600349188, + "p95_ms": 107.8126790234819, + "p99_ms": 113.29700902570039, + "max_ms": 113.29700902570039 + }, + { + "engine": "control_mode", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 30.5233730468899, + 24.231599061749876, + 25.235411943867803, + 27.411659015342593, + 24.05042899772525, + 25.479506002739072, + 28.722655959427357, + 22.07455795723945, + 22.580575081519783, + 29.86845897976309, + 27.496899012476206, + 36.62381402682513, + 37.449890980497, + 26.981694041751325, + 24.941370938904583, + 25.788024999201298, + 30.847082030959427, + 26.0819629766047, + 23.499268922023475, + 27.957514976151288 + ], + "n_ms": 20.0, + "min_ms": 22.07455795723945, + "avg_ms": 27.39228744758293, + "median_ms": 26.531828509178013, + "p90_ms": 30.847082030959427, + "p95_ms": 36.62381402682513, + "p99_ms": 37.449890980497, + "max_ms": 37.449890980497 + }, + { + "engine": "imsg", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 73.45083705149591, + 70.16680098604411, + 78.33038899116218, + 74.66395699884742, + 56.962854927405715, + 57.84656805917621, + 64.33053908403963, + 54.64023910462856, + 58.12385701574385, + 62.07921903114766, + 55.78872701153159, + 59.88113197963685, + 63.08747094590217, + 69.321816903539, + 56.98866699822247, + 71.06748304795474, + 59.83901908621192, + 61.00640504155308, + 77.51034398097545, + 64.7579530486837 + ], + "n_ms": 20.0, + "min_ms": 54.64023910462856, + "avg_ms": 64.49221396469511, + "median_ms": 62.583344988524914, + "p90_ms": 74.66395699884742, + "p95_ms": 77.51034398097545, + "p99_ms": 78.33038899116218, + "max_ms": 78.33038899116218 + }, + { + "engine": "mock", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 0.2903119893744588, + 0.28088700491935015, + 0.3294319612905383, + 0.2774670720100403, + 0.28050492983311415, + 0.29172003269195557, + 0.2925050212070346, + 0.4807590739801526, + 0.30473608057945967, + 0.33838499803096056, + 0.2801839727908373, + 0.26983011048287153, + 0.31153589952737093, + 0.30686904210597277, + 0.26840297505259514, + 0.2645669737830758, + 0.3714329795911908, + 0.28299796395003796, + 0.2705819206312299, + 0.28041808400303125 + ], + "n_ms": 20.0, + "min_ms": 0.2645669737830758, + "avg_ms": 0.3036764042917639, + "median_ms": 0.2866549766622484, + "p90_ms": 0.33838499803096056, + "p95_ms": 0.3714329795911908, + "p99_ms": 0.4807590739801526, + "max_ms": 0.4807590739801526 + }, + { + "engine": "pipelined", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 16.53240597806871, + 15.442625037394464, + 19.34879703912884, + 22.86289702169597, + 18.47324299160391, + 18.982085050083697, + 24.889598018489778, + 19.762809039093554, + 17.981484066694975, + 19.241684931330383, + 20.058405003510416, + 20.32635302748531, + 19.626682973466814, + 21.44766692072153, + 25.036148028448224, + 22.131431964226067, + 27.264841948635876, + 27.298758970573545, + 25.995625066570938, + 28.35541300009936 + ], + "n_ms": 20.0, + "min_ms": 15.442625037394464, + "avg_ms": 21.552947803866118, + "median_ms": 20.192379015497863, + "p90_ms": 27.264841948635876, + "p95_ms": 27.298758970573545, + "p99_ms": 28.35541300009936, + "max_ms": 28.35541300009936 + }, + { + "engine": "classic", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 1281.397273996845, + 1210.668591898866, + 1242.258501937613, + 1174.9852310167626, + 1330.7410050183535, + 1521.1127869552001, + 1366.0107220057398, + 1602.0200490020216, + 1404.2648519389331, + 1466.6325360303745, + 1482.5694229220971, + 1441.553378943354, + 1442.840927047655, + 1607.132775010541, + 1580.0651350291446, + 1381.9000310031697, + 1454.8676230479032, + 1629.573607002385, + 1485.2986589539796, + 1375.63347897958 + ], + "n_ms": 20.0, + "min_ms": 1174.9852310167626, + "avg_ms": 1424.076329387026, + "median_ms": 1442.1971529955044, + "p90_ms": 1602.0200490020216, + "p95_ms": 1607.132775010541, + "p99_ms": 1629.573607002385, + "max_ms": 1629.573607002385 + }, + { + "engine": "subprocess", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 230.99103895947337, + 199.28296492435038, + 270.25563502684236, + 317.19566497486085, + 455.45686106197536, + 497.62218398973346, + 347.6630869554356, + 265.15684905461967, + 224.24249001778662, + 214.0263100154698, + 215.80458094831556, + 252.42189702112228, + 245.46299397479743, + 257.7294419752434, + 194.6850820677355, + 246.80601397994906, + 246.52875203173608, + 262.4232630478218, + 235.30278506223112, + 216.59297600854188 + ], + "n_ms": 20.0, + "min_ms": 194.6850820677355, + "avg_ms": 269.7825435549021, + "median_ms": 246.66738300584257, + "p90_ms": 347.6630869554356, + "p95_ms": 455.45686106197536, + "p99_ms": 497.62218398973346, + "max_ms": 497.62218398973346 + }, + { + "engine": "control_mode", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 75.78025595284998, + 79.53090802766383, + 236.67864105664194, + 233.00717200618237, + 328.0731838895008, + 131.67984399478883, + 164.9903169600293, + 336.2570349127054, + 328.47389997914433, + 113.03125496488065, + 83.44777394086123, + 72.73705000989139, + 70.41866099461913, + 179.36235608067364, + 102.63979400042444, + 71.07233500573784, + 73.72917397879064, + 70.85887296125293, + 104.05752004589885, + 94.78877391666174 + ], + "n_ms": 20.0, + "min_ms": 70.41866099461913, + "avg_ms": 147.53074113395996, + "median_ms": 103.34865702316165, + "p90_ms": 328.0731838895008, + "p95_ms": 328.47389997914433, + "p99_ms": 336.2570349127054, + "max_ms": 336.2570349127054 + }, + { + "engine": "imsg", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 134.2119559412822, + 210.86893905885518, + 148.33857398480177, + 138.0466379923746, + 154.90469709038734, + 130.4093300132081, + 151.83229907415807, + 218.34416908677667, + 124.5981550309807, + 128.86274000629783, + 159.59694795310497, + 145.80014697276056, + 135.36079099867493, + 260.35256509203464, + 163.294667028822, + 218.46147894393653, + 177.09315801039338, + 157.00659702997655, + 137.62785703875124, + 177.16083896812052 + ], + "n_ms": 20.0, + "min_ms": 124.5981550309807, + "avg_ms": 163.6086272657849, + "median_ms": 153.3684980822727, + "p90_ms": 218.34416908677667, + "p95_ms": 218.46147894393653, + "p99_ms": 260.35256509203464, + "max_ms": 260.35256509203464 + }, + { + "engine": "mock", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 1.1191670782864094, + 0.9929570369422436, + 0.8589229546487331, + 0.7576720090582967, + 1.3178159715607762, + 1.1864739935845137, + 1.3520210050046444, + 1.2667239643633366, + 1.0587309952825308, + 1.7452990869060159, + 1.4916330110281706, + 3.988807089626789, + 2.0200000144541264, + 1.7959449905902147, + 1.871323911473155, + 1.5445369062945247, + 1.4150440692901611, + 1.2863259762525558, + 0.6520430324599147, + 0.6350380135700107 + ], + "n_ms": 20.0, + "min_ms": 0.6350380135700107, + "avg_ms": 1.4178240555338562, + "median_ms": 1.302070973906666, + "p90_ms": 1.871323911473155, + "p95_ms": 2.0200000144541264, + "p99_ms": 3.988807089626789, + "max_ms": 3.988807089626789 + }, + { + "engine": "pipelined", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 113.15120500512421, + 54.02535106986761, + 64.92527201771736, + 78.20391294080764, + 57.41404299624264, + 70.83705102559179, + 92.94041607063264, + 56.410389952361584, + 69.41384205128998, + 65.76590496115386, + 108.70656406041235, + 64.65335004031658, + 75.56975504849106, + 68.74816899653524, + 75.74488001409918, + 60.71585509926081, + 57.00136907398701, + 61.06731400359422, + 58.447972987778485, + 59.52235695440322 + ], + "n_ms": 20.0, + "min_ms": 54.02535106986761, + "avg_ms": 70.66324871848337, + "median_ms": 65.34558848943561, + "p90_ms": 92.94041607063264, + "p95_ms": 108.70656406041235, + "p99_ms": 113.15120500512421, + "max_ms": 113.15120500512421 + }, + { + "engine": "classic", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 3666.3628419628367, + 3372.8903520386666, + 3848.983636009507, + 3508.1395419547334, + 3486.258820979856, + 3584.1793949948624, + 3485.946947010234, + 3771.2144720135257, + 3931.1889680102468, + 4432.214422966354, + 4076.763738063164, + 2764.4857480190694, + 3215.3420510003343, + 3295.630355947651, + 3329.8535760259256, + 3519.156881957315, + 3853.867839090526, + 2536.271504010074, + 2208.0091719981283, + 2192.4075819551945 + ], + "n_ms": 20.0, + "min_ms": 2192.4075819551945, + "avg_ms": 3403.95839230041, + "median_ms": 3497.1991814672947, + "p90_ms": 3931.1889680102468, + "p95_ms": 4076.763738063164, + "p99_ms": 4432.214422966354, + "max_ms": 4432.214422966354 + }, + { + "engine": "subprocess", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 392.0094169443473, + 399.5577469468117, + 461.8747670901939, + 463.75522401649505, + 403.79654499702156, + 478.78038801718503, + 486.85317998752, + 401.86715801246464, + 385.10203699115664, + 421.61177797243, + 463.4238800499588, + 357.96659102197737, + 435.90391403995454, + 470.3146460233256, + 480.929414043203, + 438.19006194826216, + 395.19416098482907, + 373.0431740405038, + 367.8618990816176, + 445.61960094142705 + ], + "n_ms": 20.0, + "min_ms": 357.96659102197737, + "avg_ms": 426.18277915753424, + "median_ms": 428.75784600619227, + "p90_ms": 478.78038801718503, + "p95_ms": 480.929414043203, + "p99_ms": 486.85317998752, + "max_ms": 486.85317998752 + }, + { + "engine": "control_mode", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 152.8084089513868, + 117.58937302511185, + 195.70027699228376, + 138.3822859497741, + 397.9049799963832, + 215.567508013919, + 138.99186393246055, + 188.69415298104286, + 189.87572100013494, + 173.7240820657462, + 158.34677510429174, + 215.0000180117786, + 194.2786539439112, + 176.19880801066756, + 153.5388040356338, + 159.58266996312886, + 143.19772401358932, + 153.82824302650988, + 178.53682104032487, + 147.99589000176638 + ], + "n_ms": 20.0, + "min_ms": 117.58937302511185, + "avg_ms": 179.48715300299227, + "median_ms": 166.65337601443753, + "p90_ms": 215.0000180117786, + "p95_ms": 215.567508013919, + "p99_ms": 397.9049799963832, + "max_ms": 397.9049799963832 + }, + { + "engine": "imsg", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 454.6383459819481, + 259.375739027746, + 249.91479504387826, + 420.9431590279564, + 222.0811820589006, + 264.5984790287912, + 231.42871004529297, + 333.20740202907473, + 254.01110399980098, + 341.4921569637954, + 272.3997130524367, + 239.1474029282108, + 242.0606450177729, + 231.84302891604602, + 285.94926302321255, + 250.6428079213947, + 266.55483595095575, + 276.24492603354156, + 326.5154130058363, + 226.46799904759973 + ], + "n_ms": 20.0, + "min_ms": 222.0811820589006, + "avg_ms": 282.4758554052096, + "median_ms": 261.9871090282686, + "p90_ms": 341.4921569637954, + "p95_ms": 420.9431590279564, + "p99_ms": 454.6383459819481, + "max_ms": 454.6383459819481 + }, + { + "engine": "mock", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 1.0853420244529843, + 0.9852750226855278, + 0.8930320618674159, + 1.6033079009503126, + 1.4309020480141044, + 1.3377750292420387, + 2.1038519917055964, + 1.9198539666831493, + 1.9366199849173427, + 2.0881620002910495, + 1.9528960110619664, + 2.314181998372078, + 1.6242529964074492, + 1.7207990167662501, + 1.67950801551342, + 1.1982190189883113, + 1.0953579330816865, + 1.4674999983981252, + 1.091616926714778, + 1.1784050147980452 + ], + "n_ms": 20.0, + "min_ms": 0.8930320618674159, + "avg_ms": 1.5353429480455816, + "median_ms": 1.535403949674219, + "p90_ms": 2.0881620002910495, + "p95_ms": 2.1038519917055964, + "p99_ms": 2.314181998372078, + "max_ms": 2.314181998372078 + }, + { + "engine": "pipelined", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 100.61924997717142, + 96.57052101101726, + 99.77939596865326, + 117.95211094431579, + 113.54388005565852, + 98.9359940867871, + 124.54905500635505, + 101.82711097877473, + 156.30723407957703, + 125.18065399490297, + 110.4188309982419, + 133.29723698552698, + 140.21150907501578, + 165.18471902236342, + 121.83465505950153, + 193.51797795388848, + 152.09435508586466, + 102.82438900321722, + 101.34623397607356, + 110.86194100789726 + ], + "n_ms": 20.0, + "min_ms": 96.57052101101726, + "avg_ms": 123.3428527135402, + "median_ms": 115.74799549998716, + "p90_ms": 156.30723407957703, + "p95_ms": 165.18471902236342, + "p99_ms": 193.51797795388848, + "max_ms": 193.51797795388848 + } +] \ No newline at end of file diff --git a/scripts/bench-results/wait.json b/scripts/bench-results/wait.json new file mode 100644 index 000000000..0b138f7c4 --- /dev/null +++ b/scripts/bench-results/wait.json @@ -0,0 +1,314 @@ +[ + { + "engine": "classic", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 166.7344990419224, + 212.69031206611544, + 256.8014459684491, + 199.50575497932732, + 261.5824770182371, + 222.48539805877954, + 203.23852205183357, + 242.7966770483181, + 239.08080800902098, + 198.84139497298747 + ], + "n_ms": 10.0, + "min_ms": 166.7344990419224, + "avg_ms": 220.3757289214991, + "median_ms": 217.5878550624475, + "p90_ms": 256.8014459684491, + "p95_ms": 261.5824770182371, + "p99_ms": 261.5824770182371, + "max_ms": 261.5824770182371 + }, + { + "engine": "control_mode", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 8.247727062553167, + 10.702441912144423, + 14.183179941028357, + 10.467821964994073, + 8.07721505407244, + 9.324315935373306, + 8.775111986324191, + 9.275623015128076, + 9.485395043157041, + 13.174964929930866 + ], + "n_ms": 10.0, + "min_ms": 8.07721505407244, + "avg_ms": 10.171379684470594, + "median_ms": 9.404855489265174, + "p90_ms": 13.174964929930866, + "p95_ms": 14.183179941028357, + "p99_ms": 14.183179941028357, + "max_ms": 14.183179941028357 + }, + { + "engine": "pipelined", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 6.229061051271856, + 6.618206040002406, + 8.960460894741118, + 11.124748038128018, + 7.881589001044631, + 11.010617017745972, + 8.948027971200645, + 7.985224016010761, + 10.071107069961727, + 15.785112977027893 + ], + "n_ms": 10.0, + "min_ms": 6.229061051271856, + "avg_ms": 9.461415407713503, + "median_ms": 8.954244432970881, + "p90_ms": 11.124748038128018, + "p95_ms": 15.785112977027893, + "p99_ms": 15.785112977027893, + "max_ms": 15.785112977027893 + }, + { + "engine": "classic", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 1330.9594680322334, + 1398.6252510221675, + 1403.3046170370653, + 1252.3579199332744, + 1248.6419779015705, + 1235.2563559543341, + 1639.7459730505943, + 1629.7535400371999, + 1400.728255044669, + 1332.566024037078 + ], + "n_ms": 10.0, + "min_ms": 1235.2563559543341, + "avg_ms": 1387.1939382050186, + "median_ms": 1365.5956375296228, + "p90_ms": 1629.7535400371999, + "p95_ms": 1639.7459730505943, + "p99_ms": 1639.7459730505943, + "max_ms": 1639.7459730505943 + }, + { + "engine": "control_mode", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 68.88843094930053, + 78.3604410244152, + 78.01781396847218, + 72.06234894692898, + 72.52011599484831, + 73.72508093249053, + 64.87809692043811, + 69.0728749614209, + 97.45409805327654, + 90.78932600095868 + ], + "n_ms": 10.0, + "min_ms": 64.87809692043811, + "avg_ms": 76.576862775255, + "median_ms": 73.12259846366942, + "p90_ms": 90.78932600095868, + "p95_ms": 97.45409805327654, + "p99_ms": 97.45409805327654, + "max_ms": 97.45409805327654 + }, + { + "engine": "pipelined", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 75.46699501108378, + 58.07583301793784, + 62.140439986251295, + 74.83123405836523, + 57.96935607213527, + 70.67135605029762, + 62.06154392566532, + 72.16213690117002, + 65.47302007675171, + 67.81659903936088 + ], + "n_ms": 10.0, + "min_ms": 57.96935607213527, + "avg_ms": 66.6668514139019, + "median_ms": 66.6448095580563, + "p90_ms": 74.83123405836523, + "p95_ms": 75.46699501108378, + "p99_ms": 75.46699501108378, + "max_ms": 75.46699501108378 + }, + { + "engine": "classic", + "shape": "1x4", + "panes": 4, + "wait": true, + "samples_ms": [ + 2271.074099931866, + 1628.9673490682617, + 1180.6232570670545, + 1546.2298300117254, + 1129.7054740134627, + 1003.3381689572707, + 1103.009557002224, + 1139.2105700215325, + 1072.3191909492016, + 910.3259799303487 + ], + "n_ms": 10.0, + "min_ms": 910.3259799303487, + "avg_ms": 1298.4803476952948, + "median_ms": 1134.4580220174976, + "p90_ms": 1628.9673490682617, + "p95_ms": 2271.074099931866, + "p99_ms": 2271.074099931866, + "max_ms": 2271.074099931866 + }, + { + "engine": "control_mode", + "shape": "1x4", + "panes": 4, + "wait": true, + "samples_ms": [ + 771.0779230110347, + 826.4453769661486, + 858.2343220477924, + 749.7300069080666, + 872.2627089591697, + 856.7365389317274, + 970.8761879010126, + 1058.184701949358, + 969.4039679598063, + 1015.9691049484536 + ], + "n_ms": 10.0, + "min_ms": 749.7300069080666, + "avg_ms": 894.892083958257, + "median_ms": 865.248515503481, + "p90_ms": 1015.9691049484536, + "p95_ms": 1058.184701949358, + "p99_ms": 1058.184701949358, + "max_ms": 1058.184701949358 + }, + { + "engine": "pipelined", + "shape": "1x4", + "panes": 4, + "wait": true, + "samples_ms": [ + 924.671886023134, + 957.4840500717983, + 970.9887669887394, + 717.6021320046857, + 1027.1775299916044, + 1078.7920550210401, + 1047.6982130203396, + 1131.4134739805013, + 1078.2063950318843, + 981.4246169989929 + ], + "n_ms": 10.0, + "min_ms": 717.6021320046857, + "avg_ms": 991.545911913272, + "median_ms": 1004.3010734952986, + "p90_ms": 1078.7920550210401, + "p95_ms": 1131.4134739805013, + "p99_ms": 1131.4134739805013, + "max_ms": 1131.4134739805013 + }, + { + "engine": "classic", + "shape": "5x4", + "panes": 20, + "wait": true, + "samples_ms": [ + 2688.125988934189, + 3421.498993993737, + 2721.125219017267, + 3680.0719080492854, + 3053.872239892371, + 3266.9221319956705, + 3363.687581033446, + 3229.252888006158, + 3476.997076999396, + 3238.5111950570717 + ], + "n_ms": 10.0, + "min_ms": 2688.125988934189, + "avg_ms": 3214.006522297859, + "median_ms": 3252.716663526371, + "p90_ms": 3476.997076999396, + "p95_ms": 3680.0719080492854, + "p99_ms": 3680.0719080492854, + "max_ms": 3680.0719080492854 + }, + { + "engine": "control_mode", + "shape": "5x4", + "panes": 20, + "wait": true, + "samples_ms": [ + 2327.0793380215764, + 2169.6944349678233, + 2290.0202609598637, + 2193.4810240054503, + 2250.2999720163643, + 2082.6522540301085, + 2308.945218101144, + 2176.221609930508, + 2195.5926649970934, + 2136.908065993339 + ], + "n_ms": 10.0, + "min_ms": 2082.6522540301085, + "avg_ms": 2213.089484302327, + "median_ms": 2194.536844501272, + "p90_ms": 2308.945218101144, + "p95_ms": 2327.0793380215764, + "p99_ms": 2327.0793380215764, + "max_ms": 2327.0793380215764 + }, + { + "engine": "pipelined", + "shape": "5x4", + "panes": 20, + "wait": true, + "samples_ms": [ + 2107.4311519041657, + 2110.2887169690803, + 2249.8882319778204, + 2227.454266976565, + 2197.3950610263273, + 2126.2204200029373, + 2120.3758959891275, + 2134.5664139371365, + 2097.358933999203, + 2115.1353849563748 + ], + "n_ms": 10.0, + "min_ms": 2097.358933999203, + "avg_ms": 2148.611447773874, + "median_ms": 2123.2981579960324, + "p90_ms": 2227.454266976565, + "p95_ms": 2249.8882319778204, + "p99_ms": 2249.8882319778204, + "max_ms": 2249.8882319778204 + } +] \ No newline at end of file diff --git a/scripts/bench_engines.py b/scripts/bench_engines.py new file mode 100644 index 000000000..e613d21ce --- /dev/null +++ b/scripts/bench_engines.py @@ -0,0 +1,1167 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = ["rich>=13", "typer>=0.12", "libtmux"] +# +# [tool.uv.sources] +# libtmux = { path = "..", editable = true } +# /// + +# ``typer`` is a PEP 723 inline dependency, resolved only inside ``uv run``'s +# ephemeral venv; the repo's mypy environment can't import it, which also makes +# its command decorators look untyped. Suppress just those two environment +# artifacts -- every other check (including the ImsgForServer narrowing below) +# stays strict. +# mypy: disable-error-code="import-not-found, untyped-decorator" +"""Hermetic libtmux engine build-benchmark grid. + +Measures how long the experimental workspace builder (and the classic API, and a +hand-rolled pipelined prototype) take to build tmux session structures, sweeping +scenarios x engines x wait-modes. Reports min/avg/median/max/p90/p95/p99. + +Hermetic & sandboxed: every server runs on its OWN socket under a throwaway +mkdtemp dir; ``TMUX`` is unset at import so the ambient session is never touched; +an ``atexit`` hook kills every spawned server (and any orphan on our sockets) and +removes the dir. The default server is never contacted. + +Engines (``--engines``): + classic classic libtmux Server/Session/Window/Pane API (subprocess) + subprocess builder on SubprocessEngine (one tmux fork per op) + control_mode builder on ControlModeEngine (one persistent ``tmux -C``) + imsg builder on ImsgEngine (AF_UNIX imsg, socket-injected) + mock builder on MockEngine (offline, in-memory: Python floor) + pipelined prototype: batch independent creates via run_batch (control_mode) + +Timing (``run`` = in-process build-only, the clean signal; ``--hyperfine`` also +runs whole-process wall time via hyperfine over the ``cell`` subcommand). + +The ``matrix`` subcommand isolates *why* a build costs what it does, sweeping a +four-axis factorial -- async {sync, async} x transport {subprocess, +control_mode} x planner {imperative, plan-seq, plan-fold} x workspace +{hand plan, declarative} -- as five expression layers x 2 transports x 2 modes, +against a ``classic`` reference. ``mock`` is not benchmarked here: it is the +offline correctness oracle, and ``matrix --check`` / ``contract`` assert every +layer x mode renders identical tmux argv to it, so the grid doubles as an +ops-language contract test. ``concurrency`` measures async's real lever -- K +independent builds sync-serial vs ``asyncio.gather`` over one connection. + +Run: uv run bench_engines.py run + uv run bench_engines.py run --engines control_mode,pipelined --wait + uv run bench_engines.py profile --engine control_mode --shape 8x4 + uv run bench_engines.py cell control_mode 8x4 # one build (for hyperfine) + uv run bench_engines.py matrix --shapes 1x4,3x3,5x4 + uv run bench_engines.py concurrency --transport control_mode --k 4 + uv run bench_engines.py contract # parity only, for CI +""" + +from __future__ import annotations + +import asyncio +import atexit +import contextlib +import cProfile +import dataclasses +import io +import itertools +import json +import math +import os +import pathlib +import pstats +import shutil +import statistics +import subprocess +import tempfile +import time +import typing as t +import uuid + +# Never inherit the ambient tmux session -- do this BEFORE importing libtmux. +os.environ.pop("TMUX", None) +os.environ.pop("TMUX_PANE", None) + +import rich.console +import rich.table +import typer + +from libtmux.experimental.engines import ( + AsyncControlModeEngine, + AsyncMockEngine, + AsyncSubprocessEngine, + ControlModeEngine, + ImsgEngine, + MockEngine, + SubprocessEngine, +) +from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.ops import ( + FoldingPlanner, + LazyPlan, + NewSession, + NewWindow, + Planner, + RenameWindow, + SequentialPlanner, + SplitWindow, + arun as op_arun, + run as op_run, +) +from libtmux.experimental.ops._types import PaneId, SessionId, SlotRef, WindowId +from libtmux.experimental.workspace import Pane, Window, Workspace +from libtmux.server import Server + +console = rich.console.Console() +# Wide console for the factorial tables: their per-cell labels (layer · transport +# · mode) overflow an 80-col pipe, so render them at a fixed width so each row +# stays on one line under redirection. +wide_console = rich.console.Console(width=132) +R = CommandRequest.from_args +_ctr = itertools.count() +STAT_LABELS = ("n", "min", "avg", "median", "p90", "p95", "p99", "max") + +# --------------------------------------------------------------------------- # +# Hermetic isolation # +# --------------------------------------------------------------------------- # +_SOCK_DIR = pathlib.Path( + tempfile.mkdtemp(prefix="ltbench-") +) # short: /tmp/ltbench-XXXX +_SERVERS: list[Server] = [] +#: A session every bench server keeps for its whole life, so killing a cell's +#: session never drops the server to zero and trips tmux's exit-empty teardown. +_KEEPALIVE = "keepalive" + + +def new_server() -> Server: + """Return a fresh isolated server on a unique socket under the scratch dir. + + The server is pinned alive by a keepalive session. Every cell kills its + session between builds, which would otherwise drop the server to zero + sessions; under tmux's ``exit-empty`` default the server then starts + exiting, and the next build's ``new-session`` can reach the still-bound + socket mid-shutdown and fail with "server exited unexpectedly". The race is + load-dependent, so it surfaced as an intermittent create failure rather than + an obvious teardown bug. Control mode never hit it -- its ``tmux -C`` + phantom session already pinned the server -- which is exactly why only the + subprocess cells were affected. + """ + srv = Server(socket_path=str(_SOCK_DIR / f"{uuid.uuid4().hex[:8]}.sock")) + _SERVERS.append(srv) + # The keepalive has to come first: `start-server` alone leaves a server with + # zero sessions, which exits immediately under the default, so there is no + # server left to set the option on. Creating a session that is never killed + # is what actually holds the floor above zero. + srv.cmd("new-session", "-d", "-s", _KEEPALIVE) + srv.cmd("set-option", "-s", "exit-empty", "off") + return srv + + +def _cleanup() -> None: + for srv in _SERVERS: + with contextlib.suppress(Exception): + srv.kill() + # Backstop: SIGKILL any tmux server still bound to a socket in our dir. + with contextlib.suppress(Exception): + out = subprocess.run( + ["pgrep", "-f", f"tmux .*-S{_SOCK_DIR}/"], + capture_output=True, + text=True, + check=False, + ).stdout.split() + for pid in out: + with contextlib.suppress(Exception): + os.kill(int(pid), 9) + with contextlib.suppress(Exception): + shutil.rmtree(_SOCK_DIR, ignore_errors=True) + + +def _reap_stale_scratch() -> None: + """Remove scratch dirs left behind by runs that died before their cleanup. + + :func:`_cleanup` only knows *this* process's socket dir, so a run killed + before its ``atexit`` hook leaves its dir -- and any tmux still bound to + it -- behind for good. Those survivors keep consuming CPU and file + descriptors, and machine load is precisely what makes the server-teardown + race fire, so an unreaped leak feeds the very failure it came from. + + A dir with a live tmux is left alone: it may belong to a concurrent run, and + stealing another run's servers would be worse than leaking. That means hung + clients are only reclaimed once their server is gone, which is the + conservative trade. + """ + reaped = 0 + for path in pathlib.Path(tempfile.gettempdir()).glob("ltbench-*"): + if path == _SOCK_DIR or not path.is_dir(): + continue + with contextlib.suppress(Exception): + alive = subprocess.run( + ["pgrep", "-f", f"tmux .*-S{path}/"], + capture_output=True, + text=True, + check=False, + ).stdout.split() + if alive: + continue + shutil.rmtree(path, ignore_errors=True) + reaped += 1 + if reaped: + console.print(f"[dim]reaped {reaped} stale bench scratch dir(s)[/dim]") + + +atexit.register(_cleanup) +_reap_stale_scratch() + + +def uniq() -> str: + """Return a process-unique session name (never collides across builds).""" + return f"b{next(_ctr)}" + + +# --------------------------------------------------------------------------- # +# Scenario spec + build implementations # +# --------------------------------------------------------------------------- # +def parse_shape(s: str) -> tuple[int, int]: + """'8x4' -> (8 windows, 4 panes-per-window).""" + w, _, p = s.lower().partition("x") + return int(w), int(p) + + +def spec(name: str, wins: int, panes: int) -> Workspace: + """Build the declarative Workspace IR for *wins* windows x *panes* panes.""" + return Workspace( + name=name, + on_exists="replace", + windows=[ + Window(name=f"w{w}", panes=[Pane() for _ in range(panes)]) + for w in range(wins) + ], + ) + + +def build_classic(server: Server, name: str, wins: int, panes: int) -> None: + """Build the structure with the classic Server/Session/Window/Pane API.""" + session = server.new_session(session_name=name, window_name="w0") + for _ in range(panes - 1): + session.active_window.split() + for wi in range(1, wins): + window = session.new_window(window_name=f"w{wi}") + for _ in range(panes - 1): + window.split() + + +def build_pipelined(engine: t.Any, name: str, wins: int, panes: int) -> None: + """Prototype: batch INDEPENDENT creates into few run_batch round-trips. + + new-session (1) + all new-windows in one run_batch (1) + all splits in one + run_batch (1) = 3 round-trips for any shape, vs ~1-per-op for the builder. + The control-mode run_batch pipelines (write all, read all reply blocks). + """ + engine.run(R("new-session", "-d", "-s", name, "-n", "w0")) + if wins > 1: + engine.run_batch( + [R("new-window", "-t", name, "-n", f"w{i}") for i in range(1, wins)] + ) + splits = [ + R("split-window", "-t", f"{name}:w{i}") + for i in range(wins) + for _ in range(panes - 1) + ] + if splits: + engine.run_batch(splits) + + +class ImsgForServer: + """Bind ImsgEngine to a specific server by injecting ``-S`` per call. + + ImsgEngine has no ``for_server`` -- it parses ``-L``/``-S`` from the command + args -- so this wrapper prepends the isolated socket flag to every request. + """ + + def __init__(self, server: Server | None) -> None: + assert server is not None + self._e = ImsgEngine() + self._flag = ( + f"-S{server.socket_path}" + if server.socket_path + else f"-L{server.socket_name}" + ) + + def run(self, req: CommandRequest) -> t.Any: + """Run one request with the socket flag injected.""" + return self._e.run(R(self._flag, *req.args)) + + def run_batch(self, reqs: t.Sequence[CommandRequest]) -> t.Any: + """Run a batch of requests, each with the socket flag injected.""" + return self._e.run_batch([R(self._flag, *r.args) for r in reqs]) + + def tmux_version(self) -> t.Any: + """Report the underlying imsg engine's tmux version.""" + return self._e.tmux_version() + + +@dataclasses.dataclass(frozen=True) +class Impl: + """One benchmarked implementation: how to make its engine and build.""" + + name: str + kind: str # classic | builder | pipelined | offline + make_engine: t.Callable[[Server | None], t.Any] | None = None + needs_preboot: bool = False + preflight: bool = True + + +IMPLS: dict[str, Impl] = { + "classic": Impl("classic", "classic"), + "subprocess": Impl( + "subprocess", "builder", lambda s: SubprocessEngine.for_server(s) + ), + "control_mode": Impl( + "control_mode", "builder", lambda s: ControlModeEngine.for_server(s) + ), + "imsg": Impl("imsg", "builder", lambda s: ImsgForServer(s), needs_preboot=True), + "mock": Impl("mock", "offline", lambda s: MockEngine(), preflight=False), + "pipelined": Impl( + "pipelined", "pipelined", lambda s: ControlModeEngine.for_server(s) + ), +} + + +def do_build( + impl: Impl, server: Server | None, engine: t.Any, name: str, w: int, p: int +) -> None: + """Dispatch one build of *w* x *p* to the right implementation path.""" + if impl.kind == "classic": + build_classic(server, name, w, p) # type: ignore[arg-type] + elif impl.kind == "pipelined": + build_pipelined(engine, name, w, p) + else: # builder / offline + spec(name, w, p).build(engine, preflight=impl.preflight) + + +def wait_ready( + server: Server, name: str, timeout: float = 2.0, interval: float = 0.015 +) -> None: + """Poll each pane until its shell has drawn something (a prompt) or timeout. + + Models the classic ``_wait_for_pane_ready`` cost -- the shell-readiness wait + that inflates 'realistic' build times. Engine-agnostic (reads via subprocess). + """ + ids = [ + x + for x in server.cmd("list-panes", "-s", "-t", name, "-F", "#{pane_id}").stdout + if x + ] + pending = set(ids) + deadline = time.monotonic() + timeout + while pending and time.monotonic() < deadline: + for pid in list(pending): + cap = server.cmd("capture-pane", "-p", "-t", pid).stdout + if any(line.strip() for line in cap): + pending.discard(pid) + if pending: + time.sleep(interval) + + +def run_cell( + impl: Impl, wins: int, panes: int, wait: bool, runs: int, warmup: int +) -> list[float]: + """Return per-build wall times (ms), in-process, with session cleanup.""" + if impl.kind == "offline": + engine = impl.make_engine(None) # type: ignore[misc] + for _ in range(warmup): + spec(uniq(), wins, panes).build(engine, preflight=False) + samples = [] + for _ in range(runs): + name = uniq() + t0 = time.perf_counter() + spec(name, wins, panes).build(engine, preflight=False) + samples.append((time.perf_counter() - t0) * 1000) + return samples + + server = new_server() + if impl.needs_preboot: + server.cmd("start-server") + engine = impl.make_engine(server) if impl.make_engine else None + try: + for _ in range(warmup): + name = uniq() + do_build(impl, server, engine, name, wins, panes) + if wait: + wait_ready(server, name) + server.cmd("kill-session", "-t", name) + samples = [] + for _ in range(runs): + name = uniq() + t0 = time.perf_counter() + do_build(impl, server, engine, name, wins, panes) + if wait: + wait_ready(server, name) + samples.append((time.perf_counter() - t0) * 1000) + server.cmd("kill-session", "-t", name) # untimed cleanup -> no accumulation + return samples + finally: + with contextlib.suppress(Exception): + server.kill() + + +# --------------------------------------------------------------------------- # +# Stats (nearest-rank percentiles, like agentgrep's benchmark) # +# --------------------------------------------------------------------------- # +def percentile(sorted_vals: list[float], pct: float) -> float: + """Nearest-rank percentile of a pre-sorted sequence.""" + if not sorted_vals: + return float("nan") + rank = max(1, math.ceil(pct / 100.0 * len(sorted_vals))) + return sorted_vals[min(rank, len(sorted_vals)) - 1] + + +def summarize(samples: list[float]) -> dict[str, float]: + """Return min/avg/median/p90/p95/p99/max (and n) for *samples*.""" + s = sorted(samples) + return { + "n": float(len(s)), + "min": s[0], + "avg": statistics.fmean(s), + "median": statistics.median(s), + "p90": percentile(s, 90), + "p95": percentile(s, 95), + "p99": percentile(s, 99), + "max": s[-1], + } + + +# --------------------------------------------------------------------------- # +# Factorial matrix -- axes as registries, the grid via itertools.product # +# --------------------------------------------------------------------------- # +# Three orthogonal axes, each its own small registry. The matrix is *generated* +# by product() over them, never enumerated by hand -- adding an axis value (a new +# transport, a new expression layer) is one registry entry, and every cell flows +# through one generic timing core. +# +# MODES sync | async -- which run-strategy drives a build +# TRANSPORTS subprocess | control_mode -- each supplies a sync + async engine +# LAYERS imperative | plan-seq | plan-fold | ws-seq | ws-fold +# +# The five LAYERS are the valid *expression* layers: a declarative Workspace +# compiles to a LazyPlan, so ws-* and plan-* share one op spine, and folding is a +# planner swap. `mock` is NOT a layer -- it is the offline correctness oracle for +# the parity contract (`check_parity`), never a results row. +# +# Sync/async is unified without contaminating either path with the other's +# machinery: the plan/ws layers differ only by method name (`execute`/`aexecute`, +# `build`/`abuild`), and the imperative layer is a single sans-I/O generator of +# ops -- a sync pump drives it with `run`, an async pump with `arun`. That is the +# same colored-leaf split the library's own ``LazyPlan._drive`` uses. + +MODES: tuple[str, ...] = ("sync", "async") + + +def _imperative_ops( + name: str, wins: int, panes: int +) -> t.Generator[t.Any, t.Any, None]: + """Yield the WxP build as typed ops, reading each created id off its result. + + A sans-I/O generator: it ``yield``s an operation and is ``.send()`` the typed + result, from which it reads the concrete id to target the next op. Written + ONCE; :func:`_pump_sync` drives it with :func:`op_run`, :func:`_pump_async` + with :func:`op_arun`. The emitted op sequence mirrors the workspace compiler + exactly, so it renders argv-identical to every other layer under mock. + """ + result = yield NewSession(session_name=name, capture_panes=True) + session_id, window0_id, pane0_id = ( + result.new_id, + result.first_window_id, + result.first_pane_id, + ) + yield RenameWindow(target=WindowId(window0_id), name="w0") + prev = pane0_id + for _ in range(panes - 1): + result = yield SplitWindow(target=PaneId(prev)) + prev = result.new_pane_id + for wi in range(1, wins): + result = yield NewWindow( + target=SessionId(session_id), name=f"w{wi}", capture_pane=True + ) + prev = result.first_pane_id + for _ in range(panes - 1): + result = yield SplitWindow(target=PaneId(prev)) + prev = result.new_pane_id + + +def _pump_sync(gen: t.Generator[t.Any, t.Any, None], engine: t.Any) -> None: + """Drive an op-generator synchronously (``run`` one op, send its result back).""" + try: + op = next(gen) + while True: + op = gen.send(op_run(op, engine)) + except StopIteration: + pass + + +async def _pump_async(gen: t.Generator[t.Any, t.Any, None], engine: t.Any) -> None: + """Async twin of :func:`_pump_sync` (``await arun`` per op).""" + try: + op = next(gen) + while True: + op = gen.send(await op_arun(op, engine)) + except StopIteration: + pass + + +def _hand_plan(name: str, wins: int, panes: int) -> LazyPlan: + """Hand-author the WxP build as a ``LazyPlan`` with forward SlotRef targets. + + Mirrors :func:`_imperative_ops` (and the workspace compiler) op-for-op, but + records refs instead of resolving ids eagerly, so a planner can fold or + sequence the dispatch. + """ + plan = LazyPlan() + session = plan.add(NewSession(session_name=name, capture_panes=True)) + plan.add(RenameWindow(target=session.window, name="w0")) + prev: SlotRef = session.pane + for _ in range(panes - 1): + prev = plan.add(SplitWindow(target=prev)) + for wi in range(1, wins): + window = plan.add(NewWindow(target=session, name=f"w{wi}", capture_pane=True)) + prev = window.pane + for _ in range(panes - 1): + prev = plan.add(SplitWindow(target=prev)) + return plan + + +@dataclasses.dataclass(frozen=True) +class Layer: + """One expression layer: how a build is *authored* (not how it is dispatched). + + ``kind`` selects the execution shape (``imperative`` drives the generator; + ``plan`` executes a hand ``LazyPlan``; ``ws`` builds the declarative + :class:`~libtmux.experimental.workspace.Workspace` IR). ``planner`` is the + dispatch policy for the plan/ws kinds (``None`` for imperative). + """ + + name: str + kind: str # imperative | plan | ws + planner: Planner | None = None + + +LAYERS: dict[str, Layer] = { + "imperative": Layer("imperative", "imperative"), + "plan-seq": Layer("plan-seq", "plan", SequentialPlanner()), + "plan-fold": Layer("plan-fold", "plan", FoldingPlanner()), + "ws-seq": Layer("ws-seq", "ws", SequentialPlanner()), + "ws-fold": Layer("ws-fold", "ws", FoldingPlanner()), +} + + +def build_sync(layer: Layer, engine: t.Any, name: str, wins: int, panes: int) -> None: + """Execute one WxP build for *layer* over a synchronous *engine*.""" + if layer.kind == "imperative": + _pump_sync(_imperative_ops(name, wins, panes), engine) + elif layer.kind == "plan": + _hand_plan(name, wins, panes).execute(engine, planner=layer.planner) + else: # ws + spec(name, wins, panes).build(engine, preflight=False, planner=layer.planner) + + +async def build_async( + layer: Layer, engine: t.Any, name: str, wins: int, panes: int +) -> None: + """Execute one WxP build for *layer* over an asynchronous *engine*.""" + if layer.kind == "imperative": + await _pump_async(_imperative_ops(name, wins, panes), engine) + elif layer.kind == "plan": + await _hand_plan(name, wins, panes).aexecute(engine, planner=layer.planner) + else: # ws + await spec(name, wins, panes).abuild( + engine, preflight=False, planner=layer.planner + ) + + +@dataclasses.dataclass(frozen=True) +class Transport: + """One transport axis value: a sync engine factory and an async one.""" + + name: str + make_sync: t.Callable[[Server], t.Any] + make_async: t.Callable[[Server], t.Any] + + +TRANSPORTS: dict[str, Transport] = { + "subprocess": Transport( + "subprocess", + lambda s: SubprocessEngine.for_server(s), + lambda s: AsyncSubprocessEngine.for_server(s), + ), + "control_mode": Transport( + "control_mode", + lambda s: ControlModeEngine.for_server(s), + lambda s: AsyncControlModeEngine.for_server(s), + ), +} + + +@contextlib.asynccontextmanager +async def _open_async(transport: Transport, server: Server) -> t.AsyncIterator[t.Any]: + """Yield an async engine, honoring an async context manager when it is one. + + ``AsyncControlModeEngine`` holds a persistent connection (opened on enter, + closed on exit); ``AsyncSubprocessEngine`` is per-call and needs no teardown. + """ + engine = transport.make_async(server) + if hasattr(engine, "__aenter__"): + async with engine as opened: + yield opened + else: + yield engine + + +def _sync_cell( + transport: Transport, + layer: Layer, + server: Server, + wins: int, + panes: int, + runs: int, + warmup: int, +) -> list[float]: + """Time *runs* synchronous builds of one cell (untimed kill-session cleanup).""" + engine = transport.make_sync(server) + for _ in range(warmup): + name = uniq() + build_sync(layer, engine, name, wins, panes) + server.cmd("kill-session", "-t", name) + samples: list[float] = [] + for _ in range(runs): + name = uniq() + t0 = time.perf_counter() + build_sync(layer, engine, name, wins, panes) + samples.append((time.perf_counter() - t0) * 1000) + server.cmd("kill-session", "-t", name) + return samples + + +async def _akill_session(server: Server, name: str) -> None: + """Kill *name* without blocking the event loop. + + ``server.cmd`` shells out synchronously. Called straight from a coroutine it + stalls the loop mid-cell, and a blocking subprocess issued from inside a + running loop measurably raises the rate of failed ``new-session`` calls, so + the cleanup is offloaded to a thread. + """ + await asyncio.to_thread(server.cmd, "kill-session", "-t", name) + + +async def _async_cell( + transport: Transport, + layer: Layer, + server: Server, + wins: int, + panes: int, + runs: int, + warmup: int, +) -> list[float]: + """Async twin of :func:`_sync_cell`, over one persistent async connection.""" + async with _open_async(transport, server) as engine: + for _ in range(warmup): + name = uniq() + await build_async(layer, engine, name, wins, panes) + await _akill_session(server, name) + samples: list[float] = [] + for _ in range(runs): + name = uniq() + t0 = time.perf_counter() + await build_async(layer, engine, name, wins, panes) + samples.append((time.perf_counter() - t0) * 1000) + await _akill_session(server, name) + return samples + + +def matrix_cell( + transport: Transport, + mode: str, + layer: Layer, + wins: int, + panes: int, + runs: int, + warmup: int, +) -> list[float]: + """Time one generated cell (transport x mode x layer) on a fresh server.""" + server = new_server() + try: + if mode == "sync": + return _sync_cell(transport, layer, server, wins, panes, runs, warmup) + return asyncio.run( + _async_cell(transport, layer, server, wins, panes, runs, warmup) + ) + finally: + with contextlib.suppress(Exception): + server.kill() + + +# --------------------------------------------------------------------------- # +# Mock-parity contract: every layer x mode renders the SAME argv as mock # +# --------------------------------------------------------------------------- # +class _Recorder: + """Wrap a sync engine, recording each dispatched argv (the parity oracle tap).""" + + def __init__(self, inner: t.Any) -> None: + self.inner = inner + self.argv: list[tuple[str, ...]] = [] + + def run(self, request: CommandRequest) -> t.Any: + """Record and forward one request.""" + self.argv.append(tuple(request.args)) + return self.inner.run(request) + + def run_batch(self, requests: t.Sequence[CommandRequest]) -> t.Any: + """Record and forward a batch of requests.""" + self.argv.extend(tuple(r.args) for r in requests) + return self.inner.run_batch(requests) + + +class _AsyncRecorder: + """Async twin of :class:`_Recorder`.""" + + def __init__(self, inner: t.Any) -> None: + self.inner = inner + self.argv: list[tuple[str, ...]] = [] + + async def run(self, request: CommandRequest) -> t.Any: + """Record and forward one request.""" + self.argv.append(tuple(request.args)) + return await self.inner.run(request) + + async def run_batch(self, requests: t.Sequence[CommandRequest]) -> t.Any: + """Record and forward a batch of requests.""" + self.argv.extend(tuple(r.args) for r in requests) + return await self.inner.run_batch(requests) + + +def _record_sync( + layer: Layer, name: str, wins: int, panes: int +) -> list[tuple[str, ...]]: + """Render *layer* through the deterministic MockEngine, capturing its argv.""" + recorder = _Recorder(MockEngine()) + build_sync(layer, recorder, name, wins, panes) + return recorder.argv + + +async def _record_async( + layer: Layer, name: str, wins: int, panes: int +) -> list[tuple[str, ...]]: + """Async twin of :func:`_record_sync` (AsyncMockEngine).""" + recorder = _AsyncRecorder(AsyncMockEngine()) + await build_async(layer, recorder, name, wins, panes) + return recorder.argv + + +def check_parity( + wins: int, panes: int +) -> tuple[list[tuple[str, ...]], list[tuple[str, bool]]]: + """Assert every layer x mode renders the mock oracle's argv for WxP. + + Mock is deterministic, so all five expression layers -- driven through it via + both the sync and async paths -- must emit the identical op argv sequence. + The oracle is the declarative ws-seq rendering (one argv per op); returns the + oracle plus a ``(label, agrees)`` row per layer x mode. + """ + name = "parity" + oracle = _record_sync(LAYERS["ws-seq"], name, wins, panes) + rows: list[tuple[str, bool]] = [] + for layer_name, layer in LAYERS.items(): + rows.append( + (f"{layer_name}/sync", _record_sync(layer, name, wins, panes) == oracle) + ) + rows.append( + ( + f"{layer_name}/async", + asyncio.run(_record_async(layer, name, wins, panes)) == oracle, + ) + ) + return oracle, rows + + +# --------------------------------------------------------------------------- # +# CLI # +# --------------------------------------------------------------------------- # +app = typer.Typer(add_completion=False, help=__doc__) + + +@app.command() +def run( + shapes: str = typer.Option("1x1,1x4,3x3,5x4,8x4", help="comma WxP shapes"), + engines: str = typer.Option( + "classic,subprocess,control_mode,imsg,mock,pipelined", + help="comma engine names", + ), + wait: bool = typer.Option(False, help="ALSO measure with shell-readiness wait"), + runs: int = typer.Option(20, help="timed builds per cell"), + warmup: int = typer.Option(3, help="warmup builds per cell"), + json_out: str = typer.Option("", help="write full JSON results here"), +) -> None: + """In-process build-only benchmark grid (the clean signal).""" + shape_list = [parse_shape(s) for s in shapes.split(",") if s] + engine_list = [e for e in engines.split(",") if e in IMPLS] + wait_modes = [False, True] if wait else [False] + results: list[dict[str, t.Any]] = [] + + for wm in wait_modes: + for wins, panes in shape_list: + table = rich.table.Table( + title=f"[bold]{wins} win x {panes} pane ({wins * panes} panes)" + f"{' [wait]' if wm else ''} -- in-process build ms[/bold]" + ) + table.add_column("engine", style="cyan") + for label in STAT_LABELS: + table.add_column(label, justify="right") + table.add_column("vs classic", justify="right", style="green") + base_median = None + for name in engine_list: + impl = IMPLS[name] + if impl.kind == "offline" and wm: + continue # no real panes to wait on + samples = run_cell(impl, wins, panes, wm, runs, warmup) + st = summarize(samples) + if name == "classic": + base_median = st["median"] + speed = ( + f"{base_median / st['median']:.1f}x" + if base_median and st["median"] + else "-" + ) + table.add_row( + name, + f"{int(st['n'])}", + *[f"{st[k]:.1f}" for k in STAT_LABELS[1:]], + speed, + ) + results.append( + { + "engine": name, + "shape": f"{wins}x{panes}", + "panes": wins * panes, + "wait": wm, + "samples_ms": samples, + **{f"{k}_ms": st[k] for k in STAT_LABELS}, + } + ) + console.print(table) + console.print() + + if json_out: + pathlib.Path(json_out).write_text(json.dumps(results, indent=2)) + console.print(f"[dim]wrote {json_out}[/dim]") + + +@app.command() +def cell(engine: str, shape: str, wait: bool = typer.Option(False)) -> None: + """Build ONE workspace of *shape* with *engine* (isolated). For hyperfine.""" + impl = IMPLS[engine] + wins, panes = parse_shape(shape) + if impl.kind == "offline": + spec(uniq(), wins, panes).build(impl.make_engine(None), preflight=False) # type: ignore[misc] + return + server = new_server() + if impl.needs_preboot: + server.cmd("start-server") + eng = impl.make_engine(server) if impl.make_engine else None + try: + name = uniq() + do_build(impl, server, eng, name, wins, panes) + if wait: + wait_ready(server, name) + finally: + with contextlib.suppress(Exception): + server.kill() + + +@app.command() +def profile( + engine: str = typer.Option("control_mode"), + shape: str = typer.Option("8x4"), + builds: int = typer.Option(5), + top: int = typer.Option(18), +) -> None: + """Profile *builds* builds of *shape* with *engine*; print slowest by cumtime.""" + impl = IMPLS[engine] + wins, panes = parse_shape(shape) + server = None if impl.kind == "offline" else new_server() + if server is not None and impl.needs_preboot: + server.cmd("start-server") + eng = impl.make_engine(server) if impl.make_engine else None + try: + warm = uniq() + do_build(impl, server, eng, warm, wins, panes) # warmup + if server is not None: + server.cmd("kill-session", "-t", warm) + pr = cProfile.Profile() + pr.enable() + for _ in range(builds): + name = uniq() + do_build(impl, server, eng, name, wins, panes) + if server is not None: + server.cmd("kill-session", "-t", name) + pr.disable() + buf = io.StringIO() + pstats.Stats(pr, stream=buf).sort_stats("cumulative").print_stats(top) + console.print(f"[bold]profile: {engine} {shape} x{builds}[/bold]") + console.print(buf.getvalue()) + finally: + if server is not None: + with contextlib.suppress(Exception): + server.kill() + + +@app.command() +def matrix( + shapes: str = typer.Option("1x4", help="comma WxP shapes"), + layers: str = typer.Option(",".join(LAYERS), help="comma expression layers"), + transports: str = typer.Option(",".join(TRANSPORTS), help="comma transports"), + modes: str = typer.Option("sync,async", help="comma modes: sync,async"), + runs: int = typer.Option(10, help="timed builds per cell"), + warmup: int = typer.Option(2, help="warmup builds per cell"), + check: bool = typer.Option(True, help="run the mock-parity contract first"), + json_out: str = typer.Option("", help="write full JSON results here"), +) -> None: + """Factorial matrix: expression-layer x transport x mode, one table per shape. + + Rows are *generated* by ``product`` over the axis registries (never + hand-enumerated); ``classic`` is the reference row and ``mock`` never appears + (it is the parity oracle, run first when ``--check``). + """ + shape_list = [parse_shape(s) for s in shapes.split(",") if s] + layer_list = [name for name in layers.split(",") if name in LAYERS] + transport_list = [name for name in transports.split(",") if name in TRANSPORTS] + mode_list = [name for name in modes.split(",") if name in MODES] + results: list[dict[str, t.Any]] = [] + + for wins, panes in shape_list: + if check: + _oracle, parity_rows = check_parity(wins, panes) + failed = [label for label, agrees in parity_rows if not agrees] + if failed: + console.print( + f"[bold red]mock-parity FAILED[/bold red] for {wins}x{panes}: " + f"{', '.join(failed)}" + ) + raise typer.Exit(1) + console.print( + f"[green]mock-parity OK[/green] -- {len(parity_rows)} layer x mode " + f"agree with mock argv for {wins}x{panes}" + ) + + classic_samples = run_cell(IMPLS["classic"], wins, panes, False, runs, warmup) + base_median = summarize(classic_samples)["median"] + + table = rich.table.Table( + title=f"[bold]{wins} win x {panes} pane ({wins * panes} panes)" + f" -- in-process build ms (async x transport x layer)[/bold]" + ) + table.add_column("cell", style="cyan") + for label in STAT_LABELS: + table.add_column(label, justify="right") + table.add_column("vs classic", justify="right", style="green") + + classic_stat = summarize(classic_samples) + table.add_row( + "classic (reference)", + f"{int(classic_stat['n'])}", + *[f"{classic_stat[k]:.1f}" for k in STAT_LABELS[1:]], + "1.0x", + ) + results.append( + { + "cell": "classic", + "layer": "classic", + "transport": "classic", + "mode": "sync", + "shape": f"{wins}x{panes}", + "panes": wins * panes, + "samples_ms": classic_samples, + **{f"{k}_ms": classic_stat[k] for k in STAT_LABELS}, + } + ) + + for layer_name, transport_name, mode in itertools.product( + layer_list, transport_list, mode_list + ): + samples = matrix_cell( + TRANSPORTS[transport_name], + mode, + LAYERS[layer_name], + wins, + panes, + runs, + warmup, + ) + st = summarize(samples) + speed = ( + f"{base_median / st['median']:.1f}x" + if base_median and st["median"] + else "-" + ) + label = f"{layer_name} · {transport_name} · {mode}" + table.add_row( + label, + f"{int(st['n'])}", + *[f"{st[k]:.1f}" for k in STAT_LABELS[1:]], + speed, + ) + results.append( + { + "cell": label, + "layer": layer_name, + "transport": transport_name, + "mode": mode, + "shape": f"{wins}x{panes}", + "panes": wins * panes, + "samples_ms": samples, + **{f"{k}_ms": st[k] for k in STAT_LABELS}, + } + ) + + wide_console.print(table) + wide_console.print() + + if json_out: + pathlib.Path(json_out).write_text(json.dumps(results, indent=2)) + console.print(f"[dim]wrote {json_out}[/dim]") + + +def _serial_build( + transport: Transport, layer: Layer, server: Server, k: int, wins: int, panes: int +) -> float: + """Build *k* independent sessions serially over a sync engine; return ms.""" + engine = transport.make_sync(server) + names = [uniq() for _ in range(k)] + t0 = time.perf_counter() + for name in names: + build_sync(layer, engine, name, wins, panes) + elapsed = (time.perf_counter() - t0) * 1000 + for name in names: + server.cmd("kill-session", "-t", name) + return elapsed + + +async def _gather_build( + transport: Transport, layer: Layer, server: Server, k: int, wins: int, panes: int +) -> float: + """Build *k* independent sessions via ``asyncio.gather`` over one connection.""" + async with _open_async(transport, server) as engine: + names = [uniq() for _ in range(k)] + t0 = time.perf_counter() + await asyncio.gather( + *(build_async(layer, engine, name, wins, panes) for name in names) + ) + elapsed = (time.perf_counter() - t0) * 1000 + for name in names: + await _akill_session(server, name) + return elapsed + + +@app.command() +def contract( + shapes: str = typer.Option("1x1,1x4,2x2,3x3,5x4", help="comma WxP shapes"), +) -> None: + """Run the mock-parity contract standalone; exit non-zero on divergence. + + The benchmark's correctness oracle without the timing -- for CI, which can + assert the ops language stays consistent without paying for live builds. + Every expression layer, sync and async, must render the mock oracle's argv. + A negative control confirms the equality gate is not vacuous (the oracle is + non-empty and order-sensitive, so a dropped op would be caught). + """ + shape_list = [parse_shape(s) for s in shapes.split(",") if s] + failures: list[str] = [] + for wins, panes in shape_list: + oracle, rows = check_parity(wins, panes) + # Negative control: a non-empty oracle whose prefix differs from itself + # proves the `== oracle` check below can actually catch a dropped op. + if not oracle or oracle[:-1] == oracle: + failures.append(f"{wins}x{panes}: negative control vacuous (empty oracle)") + failures.extend( + f"{wins}x{panes}: {label} diverges from the mock oracle" + for label, agrees in rows + if not agrees + ) + if failures: + for problem in failures: + console.print(f"[red]FAIL[/red] {problem}") + raise typer.Exit(1) + console.print( + f"[green]contract OK[/green] -- every layer x {{sync,async}} renders the " + f"mock oracle's argv across {len(shape_list)} shape(s); negative control passed" + ) + + +@app.command() +def concurrency( + shape: str = typer.Option("1x4", help="WxP shape of each session"), + transport: str = typer.Option("control_mode", help="subprocess | control_mode"), + layer: str = typer.Option("ws-fold", help="expression layer"), + k: int = typer.Option(4, help="independent sessions to build"), + runs: int = typer.Option(5, help="timed repeats"), + warmup: int = typer.Option(1, help="warmup repeats"), +) -> None: + """Build K independent sessions: sync-serial vs async-gather wall time. + + Async should actually win here: one async connection pipelines K builds' + round-trips instead of blocking on each in turn. + """ + wins, panes = parse_shape(shape) + tp = TRANSPORTS[transport] + lay = LAYERS[layer] + + def timed_serial() -> float: + server = new_server() + try: + return _serial_build(tp, lay, server, k, wins, panes) + finally: + with contextlib.suppress(Exception): + server.kill() + + def timed_gather() -> float: + server = new_server() + try: + return asyncio.run(_gather_build(tp, lay, server, k, wins, panes)) + finally: + with contextlib.suppress(Exception): + server.kill() + + for _ in range(warmup): + timed_serial() + timed_gather() + serial = [timed_serial() for _ in range(runs)] + gather = [timed_gather() for _ in range(runs)] + + serial_stat = summarize(serial) + gather_stat = summarize(gather) + table = rich.table.Table( + title=f"[bold]K={k} x ({wins}x{panes}) sessions -- {transport} / {layer}" + f" -- wall ms[/bold]" + ) + table.add_column("strategy", style="cyan") + for label in STAT_LABELS: + table.add_column(label, justify="right") + table.add_column("speedup", justify="right", style="green") + table.add_row( + "sync-serial", + f"{int(serial_stat['n'])}", + *[f"{serial_stat[k]:.1f}" for k in STAT_LABELS[1:]], + "1.0x", + ) + speed = ( + f"{serial_stat['median'] / gather_stat['median']:.2f}x" + if gather_stat["median"] + else "-" + ) + table.add_row( + "async-gather", + f"{int(gather_stat['n'])}", + *[f"{gather_stat[k]:.1f}" for k in STAT_LABELS[1:]], + speed, + ) + wide_console.print(table) + + +if __name__ == "__main__": + app() diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py new file mode 100644 index 000000000..058adb519 --- /dev/null +++ b/scripts/mcp_swap.py @@ -0,0 +1,1110 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = ["tomlkit>=0.13"] +# /// +"""Swap MCP server configs across Claude / Codex / Cursor / Gemini / Grok / agy. + +Use when you want every installed agent CLI to run a local checkout of an +MCP server (editable) instead of a pinned release. ``use-local`` rewrites +each CLI's config to invoke the checkout via ``uv --directory run +``; ``revert`` restores from the timestamped backup the swap wrote. + +Defaults are derived from the current repo's ``pyproject.toml``: + +- entry command = first key of ``[project.scripts]`` +- server name = that entry with a trailing ``-mcp`` stripped + (``libtmux-engine-mcp`` -> ``libtmux-engine``), falling back to + ``project.name`` when the entry has no ``-mcp`` suffix. Deriving the + slug from the entry (not ``project.name``) keeps this repo's server + key distinct from a sibling package whose ``project.name`` differs + from its console-script name. + +Examples +-------- +```console +$ uv run scripts/mcp_swap.py detect +$ uv run scripts/mcp_swap.py status +$ uv run scripts/mcp_swap.py use-local --dry-run +$ uv run scripts/mcp_swap.py use-local +$ uv run scripts/mcp_swap.py revert +``` + +Scope +----- +This script is best-effort and intentionally narrow: + +- **Global configs only.** Writes to ``~/.cursor/mcp.json``, + ``~/.claude.json``, ``~/.codex/config.toml``, + ``~/.gemini/settings.json``, ``~/.grok/config.toml`` (TOML + ``mcp_servers``, same shape as Codex), and + ``~/.gemini/config/mcp_config.json`` (agy / Antigravity CLI, JSON + ``mcpServers`` — the shared-config file the CLI reads, sibling to the + ``config.json`` it loads at startup). Workspace / project-local configs + (``$PWD/.cursor/mcp.json``, ``$PWD/.gemini/settings.json``, + per-project ``projects..mcpServers`` entries inside + ``~/.claude.json`` *are* recognised for Claude only) are NOT + walked — workspace files for Cursor/Gemini are silently ignored. + When workspace precedence matters, run the CLI's own + ``cursor mcp add ...`` / ``gemini mcp add ...`` directly. + +- **Claude scope.** ``use-local`` and ``revert`` accept + ``--scope {user,project}``. The default ``project`` writes the + per-project entry under ``projects[].mcpServers`` — + only the current repo's directory sees the swap, matching + pre-flag behaviour. ``--scope user`` writes Claude's top-level + ``mcpServers`` fallback so every project that has no per-project + override picks up the swap; useful when QA-ing a branch across + many directories. Codex, Cursor, Gemini, Grok, and agy have no per-project + layer in their config files; the flag is silently coerced to + ``user`` for them. Both Claude scopes can coexist with + independent backups; full ``revert`` unwinds in LIFO order. +- **Simple binary detection.** Probing is ``shutil.which()`` + plus ``.exists()``. Custom install locations + (Homebrew, npm prefixes, ``~/.npm-global/bin``, + ``~/.claude/local/claude``, ``~/.gemini/local/gemini``) are picked + up only if the binary is on ``PATH``. FastMCP's installer probes + these locations directly; this script does not. +- **Single config shape per CLI.** No fallback paths, no merge of + multiple sources. If your setup deviates from the defaults above, + use the CLI's native ``mcp`` subcommand instead. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import difflib +import json +import os +import pathlib +import shutil +import sys +import tempfile +import time +import typing as t + +import tomlkit +import tomlkit.items + +CLIName = t.Literal["claude", "codex", "cursor", "gemini", "grok", "agy"] +ALL_CLIS: tuple[CLIName, ...] = ("claude", "codex", "cursor", "gemini", "grok", "agy") + +#: Claude config scope: ``"user"`` targets the user/system-level top-level +#: ``mcpServers`` fallback that applies to every project without its own +#: override; ``"project"`` targets the project-level per-project +#: ``projects..mcpServers`` node. Non-Claude CLIs have no +#: per-project scope in their config files, so for those CLIs the scope +#: is always normalised to ``"user"`` regardless of what was passed. +Scope = t.Literal["user", "project"] +ALL_SCOPES: tuple[Scope, ...] = ("user", "project") + + +def _normalize_scope(cli: CLIName, scope: Scope | None) -> Scope: + """Coerce ``scope`` to the value that actually applies to ``cli``. + + Non-Claude CLIs have no per-project config layer — every write to + them is necessarily user-level — so the flag is silently coerced to + ``"user"`` for those. For Claude, ``None`` defaults to ``"project"`` + to preserve pre-flag behaviour where the script always wrote the + per-project entry. + """ + if cli != "claude": + return "user" + return scope if scope is not None else "project" + + +def _state_key(cli: CLIName, scope: Scope) -> str: + """Compose the ``cli:scope`` key used inside the state file.""" + return f"{cli}:{scope}" + + +def _parse_state_key(key: str) -> tuple[CLIName, Scope] | None: + """Decode a ``cli:scope`` state key, returning ``None`` for malformed input. + + The script declares no compatibility contract for its state file — + schema is internal — so this only accepts the canonical + ``f"{cli}:{scope}"`` form. Hand-edited or unrecognised keys return + ``None`` so ``load_state`` can drop them without crashing. + """ + if ":" not in key: + return None + cli_str, _, scope_str = key.partition(":") + if cli_str in ALL_CLIS and scope_str in ALL_SCOPES: + return cli_str, scope_str + return None + + +def _parse_state_entry(v: dict[str, t.Any]) -> SwapEntry | None: + """Build a :class:`SwapEntry` from a raw state-file dict, or ``None``. + + Validates at the trust boundary so a hand-edited ``state.json`` can't + crash later code paths — particularly :func:`cmd_revert`'s LIFO sort, + which compares ``SwapEntry.seq_no`` and would raise ``TypeError`` on a + mixed ``int``/``str`` ordering. ``seq_no`` is coerced via ``int()``; + any ``KeyError`` (missing required field), ``ValueError`` (non-numeric + string), or ``TypeError`` (wrong shape, extra keys for the dataclass) + drops the entry silently. Same drop-on-malformed posture as + :func:`_parse_state_key`. + + Mirrors CPython's ``Lib/sched.py`` discipline: validate at the + counter's *origin* (``enterabs`` for sched, ``load_state`` here), not + at sort time. State-file schema is internal — no compatibility + contract — so silent drop is the right failure mode. + """ + try: + v = {**v, "seq_no": int(v["seq_no"])} + return SwapEntry(**v) + except (KeyError, TypeError, ValueError): + return None + + +def _xdg_state_home() -> pathlib.Path: + """Resolve ``$XDG_STATE_HOME`` per the XDG Base Directory spec. + + Defaults to ``~/.local/state`` when the env var is unset or empty. + State is the right XDG bucket here (vs. cache / config / data): the + file is machine-written, must persist across runs so ``revert`` can + locate the right backup, but is not safely deletable like cache nor + user-edited like config. + """ + env = os.environ.get("XDG_STATE_HOME") + if env: + return pathlib.Path(env) + return pathlib.Path.home() / ".local" / "state" + + +# ``-dev`` suffix in the namespace makes it loud that this is dev-only +# tooling state, distinct from the runtime ``libtmux`` package and from +# any sibling ``libtmux-mcp-dev`` swap state. +STATE_DIR = _xdg_state_home() / "libtmux-engine-mcp-dev" / "swap" +STATE_FILE = STATE_DIR / "state.json" + +BACKUP_SUFFIX_PREFIX = ".bak.mcp-swap-" + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass(frozen=True) +class CLIInfo: + """Static descriptor for a CLI's config file and discovery heuristics.""" + + name: CLIName + binary: str + config_path: pathlib.Path + fmt: t.Literal["json", "toml"] + + +CLIS: dict[CLIName, CLIInfo] = { + "claude": CLIInfo( + name="claude", + binary="claude", + config_path=pathlib.Path.home() / ".claude.json", + fmt="json", + ), + "codex": CLIInfo( + name="codex", + binary="codex", + config_path=pathlib.Path.home() / ".codex" / "config.toml", + fmt="toml", + ), + "cursor": CLIInfo( + name="cursor", + binary="cursor-agent", + config_path=pathlib.Path.home() / ".cursor" / "mcp.json", + fmt="json", + ), + "gemini": CLIInfo( + name="gemini", + binary="gemini", + config_path=pathlib.Path.home() / ".gemini" / "settings.json", + fmt="json", + ), + "grok": CLIInfo( + name="grok", + binary="grok", + config_path=pathlib.Path.home() / ".grok" / "config.toml", + fmt="toml", + ), + # Antigravity (the ``agy`` CLI). Its MCP config is the standard JSON + # ``mcpServers`` shape (same as Cursor / Gemini). The CLI reads + # ``~/.gemini/config/mcp_config.json`` — its shared-config dir, + # sibling to the ``config.json`` it loads at startup. The file may + # start empty until a server is added; ``load_config`` tolerates a + # 0-byte JSON file as ``{}``. + "agy": CLIInfo( + name="agy", + binary="agy", + config_path=(pathlib.Path.home() / ".gemini" / "config" / "mcp_config.json"), + fmt="json", + ), +} + + +@dataclasses.dataclass +class McpServerSpec: + """The portable shape shared across CLI configs.""" + + command: str + args: list[str] = dataclasses.field(default_factory=list) + env: dict[str, str] = dataclasses.field(default_factory=dict) + + def to_json_dict(self, *, include_stdio_type: bool = False) -> dict[str, t.Any]: + """Serialize to the JSON shape (Claude-extended when ``include_stdio_type``).""" + # Claude's format always includes ``type`` and ``env`` (even when empty); + # Cursor/Gemini omit both. include_stdio_type selects Claude shape. + if include_stdio_type: + return { + "type": "stdio", + "command": self.command, + "args": list(self.args), + "env": dict(self.env), + } + out: dict[str, t.Any] = {"command": self.command, "args": list(self.args)} + if self.env: + out["env"] = dict(self.env) + return out + + def is_local_uv_directory(self) -> bool: + """Return True for a ``uv --directory run `` shape.""" + return ( + self.command == "uv" and "--directory" in self.args and "run" in self.args + ) + + def local_repo_path(self) -> pathlib.Path | None: + """Extract the ``--directory`` argument, if any.""" + try: + i = self.args.index("--directory") + except ValueError: + return None + if i + 1 >= len(self.args): + return None + return pathlib.Path(self.args[i + 1]) + + +@dataclasses.dataclass +class SwapEntry: + """One CLI's bookkeeping for a swap, written to the state file.""" + + config_path: str + backup_path: str + server: str + action: t.Literal["replaced", "added"] + #: ``YYYYMMDDHHMMSS`` registration timestamp, human-readable for + #: anyone inspecting ``state.json`` directly. Sort order is enforced + #: separately via :attr:`seq_no` so this field stays purely + #: descriptive. + swapped_at: str + #: Monotonic registration counter — the primary LIFO sort key for + #: ``cmd_revert``. ``cmd_use_local`` computes the next value as + #: ``max(existing seq_nos, default=-1) + 1`` so it strictly + #: increases per swap regardless of wall-clock collisions or dict + #: iteration order. Same explicit-counter pattern CPython's + #: ``Lib/sched.py`` uses to break ties on ``Event(time, priority, + #: sequence, …)``. + seq_no: int + + +# --------------------------------------------------------------------------- +# Config IO — per format +# --------------------------------------------------------------------------- + + +def load_config(info: CLIInfo) -> t.Any: + """Parse a CLI's config file (JSON or TOML) into an editable structure. + + Empty JSON files are treated as empty objects so first-run MCP configs can + be seeded with their initial server entry. + """ + raw = info.config_path.read_bytes() + if info.fmt == "json": + text = raw.decode().strip() + return json.loads(text) if text else {} + return tomlkit.parse(raw.decode()) + + +def dump_config_bytes(info: CLIInfo, config: t.Any) -> bytes: + """Serialize an edited config back to bytes in its original format.""" + if info.fmt == "json": + return (json.dumps(config, indent=2) + "\n").encode() + return tomlkit.dumps(config).encode() + + +def atomic_write(path: pathlib.Path, data: bytes) -> None: + """Write bytes to ``path`` via tempfile + ``os.replace`` to avoid partial writes.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent)) + tmp = pathlib.Path(tmp_name) + try: + with os.fdopen(fd, "wb") as fh: + fh.write(data) + tmp.replace(path) + except Exception: + tmp.unlink(missing_ok=True) + raise + + +# --------------------------------------------------------------------------- +# Per-CLI get / set / delete (the only CLI-specific logic) +# --------------------------------------------------------------------------- + + +@t.overload +def _claude_project_node( + config: dict[str, t.Any], + repo: pathlib.Path, + *, + create: t.Literal[True], +) -> dict[str, t.Any]: ... + + +@t.overload +def _claude_project_node( + config: dict[str, t.Any], + repo: pathlib.Path, + *, + create: t.Literal[False], +) -> dict[str, t.Any] | None: ... + + +def _claude_project_node( + config: dict[str, t.Any], repo: pathlib.Path, *, create: bool +) -> dict[str, t.Any] | None: + """Return (or create) the ``projects.`` node Claude keys per-project. + + With ``create=True``, the node is unconditionally created if missing + and the return type is statically narrowed to ``dict[str, t.Any]``; + callers can drop runtime ``assert node is not None`` defensiveness. + With ``create=False``, the absence of the node is a real return value + and the type stays ``dict[str, t.Any] | None``. + + Raises ``RuntimeError`` if Claude's config layout is not the + expected ``projects..mcpServers`` mapping shape — the layout + is undocumented Claude Code internal state, so a clear error before + the atomic write beats a silent partial mutation that the backup + defense would be asked to recover from. + """ + key = str(repo.resolve()) + projects_node = config.get("projects") + if projects_node is not None and not isinstance(projects_node, dict): + msg = ( + "Claude config layout appears to have changed; expected " + f"'projects' to be a mapping but got " + f"{type(projects_node).__name__}" + ) + raise RuntimeError(msg) + projects = ( + config.setdefault("projects", {}) if create else config.get("projects", {}) + ) + raw_node = projects.get(key) + node: dict[str, t.Any] | None = None + if isinstance(raw_node, dict): + node = raw_node + elif raw_node is not None: + msg = ( + "Claude config layout appears to have changed; expected " + f"'projects[{key!r}]' to be a mapping but got " + f"{type(raw_node).__name__}" + ) + raise RuntimeError(msg) + if node is None and create: + node = {"allowedTools": [], "mcpContextUris": [], "mcpServers": {}, "env": {}} + projects[key] = node + return node + + +@t.overload +def _claude_user_servers( + config: dict[str, t.Any], *, create: t.Literal[True] +) -> dict[str, t.Any]: ... + + +@t.overload +def _claude_user_servers( + config: dict[str, t.Any], *, create: t.Literal[False] +) -> dict[str, t.Any] | None: ... + + +def _claude_user_servers( + config: dict[str, t.Any], *, create: bool +) -> dict[str, t.Any] | None: + """Return (or create) the top-level ``mcpServers`` dict — Claude user scope. + + Mirrors :func:`_claude_project_node` for the user-scope path so the + shape guard is centralised once and reused across read / write / + delete instead of duplicated at each call site (or worse, missing + on read and delete the way the inline write-side guard left them). + Same reasoning applies as for the project-scope helper: Claude's + config shape is undocumented internal state, so a clear + ``RuntimeError`` before the atomic write beats an opaque + ``AttributeError`` from ``.setdefault()`` on a non-dict. + + With ``create=True`` the dict is initialised when missing and the + return type narrows to ``dict[str, t.Any]``. With ``create=False`` + a missing key returns ``None``. + """ + raw = config.get("mcpServers") + existing: dict[str, t.Any] | None = None + if isinstance(raw, dict): + existing = raw + elif raw is not None: + msg = ( + "Claude config layout appears to have changed; expected " + f"'mcpServers' to be a mapping but got " + f"{type(raw).__name__}" + ) + raise RuntimeError(msg) + if existing is None and create: + existing = {} + config["mcpServers"] = existing + return existing + + +def get_server( + cli: CLIName, + config: t.Any, + name: str, + repo: pathlib.Path, + *, + scope: Scope = "project", +) -> McpServerSpec | None: + """Fetch the MCP server entry for ``name`` from a CLI's config, if present. + + ``scope`` only affects Claude (see :data:`Scope` for the layered shape + of ``~/.claude.json``); for Codex / Cursor / Gemini the parameter is + accepted-but-ignored because their config has no per-project layer. + """ + if cli == "claude": + if scope == "user": + servers = _claude_user_servers(config, create=False) + entry = servers.get(name) if servers else None + else: + node = _claude_project_node(config, repo, create=False) + if not node: + return None + entry = node.get("mcpServers", {}).get(name) + elif cli in ("cursor", "gemini", "agy"): + entry = config.get("mcpServers", {}).get(name) + else: # cli in ("codex", "grok") — TOML "mcp_servers" table + entry = config.get("mcp_servers", {}).get(name) + if entry is None: + return None + return _spec_from_entry(entry, fmt=CLIS[cli].fmt) + + +def set_server( + cli: CLIName, + config: t.Any, + name: str, + spec: McpServerSpec, + repo: pathlib.Path, + *, + scope: Scope = "project", +) -> t.Literal["replaced", "added"]: + """Write ``spec`` under ``name`` in a CLI's config, returning replaced/added. + + ``scope == "user"`` for Claude writes the top-level ``mcpServers`` + fallback used by every project that has no per-project override; + ``"project"`` (the default, preserving pre-flag behaviour) writes + under ``projects[abs(repo)].mcpServers``. The parameter is silently + ignored for non-Claude CLIs. + """ + if cli == "claude": + if scope == "user": + servers = _claude_user_servers(config, create=True) + had = name in servers + servers[name] = spec.to_json_dict(include_stdio_type=True) + return "replaced" if had else "added" + node = _claude_project_node(config, repo, create=True) + servers = node.setdefault("mcpServers", {}) + had = name in servers + servers[name] = spec.to_json_dict(include_stdio_type=True) + return "replaced" if had else "added" + if cli in ("cursor", "gemini", "agy"): + servers = config.setdefault("mcpServers", {}) + had = name in servers + servers[name] = spec.to_json_dict() + return "replaced" if had else "added" + if cli in ("codex", "grok"): + # tomlkit: top-level tables are accessed via dict protocol too. + mcp_servers = config.get("mcp_servers") + if mcp_servers is None: + mcp_servers = tomlkit.table() + config["mcp_servers"] = mcp_servers + had = name in mcp_servers + table = tomlkit.table() + table["command"] = spec.command + table["args"] = list(spec.args) + if spec.env: + env_tbl = tomlkit.table() + for k, v in spec.env.items(): + env_tbl[k] = v + table["env"] = env_tbl + mcp_servers[name] = table + return "replaced" if had else "added" + msg = f"unreachable: unknown CLI {cli!r}" + raise AssertionError(msg) + + +def delete_server( + cli: CLIName, + config: t.Any, + name: str, + repo: pathlib.Path, + *, + scope: Scope = "project", +) -> bool: + """Remove the entry for ``name`` from a CLI's config; return whether it existed. + + See :func:`set_server` for the meaning of ``scope`` — the parameter + is honoured for Claude and ignored for the other CLIs. + """ + if cli == "claude": + if scope == "user": + servers = _claude_user_servers(config, create=False) + if servers is not None and name in servers: + del servers[name] + return True + return False + node = _claude_project_node(config, repo, create=False) + if not node: + return False + servers = node.get("mcpServers", {}) + return servers.pop(name, None) is not None + if cli in ("cursor", "gemini", "agy"): + return config.get("mcpServers", {}).pop(name, None) is not None + if cli in ("codex", "grok"): + mcp_servers = config.get("mcp_servers") + if mcp_servers is None: + return False + if name in mcp_servers: + del mcp_servers[name] + return True + return False + msg = f"unreachable: unknown CLI {cli!r}" + raise AssertionError(msg) + + +def _spec_from_entry(entry: t.Any, *, fmt: t.Literal["json", "toml"]) -> McpServerSpec: + """Convert a raw config entry (dict or tomlkit Table) into an McpServerSpec.""" + # tomlkit items quack like dicts/lists; coerce to plain Python for our spec. + if fmt == "toml": + entry = ( + tomlkit.items.Table.unwrap(entry) + if isinstance(entry, tomlkit.items.Table) + else dict(entry) + ) + command = str(entry.get("command", "")) + raw_args = entry.get("args", []) + args = [str(a) for a in raw_args] if raw_args else [] + raw_env = entry.get("env") or {} + env = {str(k): str(v) for k, v in dict(raw_env).items()} + return McpServerSpec(command=command, args=args, env=env) + + +# --------------------------------------------------------------------------- +# Repo metadata +# --------------------------------------------------------------------------- + + +def resolve_repo_meta(repo: pathlib.Path) -> tuple[str, str]: + """Derive (server_name, entry_command) from the repo's pyproject.toml. + + The server name is the registration slug used as the config-file key + (``mcpServers.`` in JSON, ``[mcp_servers.]`` in TOML). + Default: the first ``[project.scripts]`` entry with a trailing + ``-mcp`` stripped (``libtmux-engine-mcp`` → ``libtmux-engine``), + falling back to ``project.name`` when the entry has no ``-mcp`` + suffix. Deriving the slug from the entry rather than ``project.name`` + keeps this repo's server key (``libtmux-engine``) distinct from a + sibling package whose ``project.name`` is ``libtmux`` — both can be + registered side by side. Pass ``--server `` to override. + """ + pyproject = repo / "pyproject.toml" + doc = tomlkit.parse(pyproject.read_text()) + project = doc.get("project") + if project is None: + msg = f"{pyproject} has no [project] table" + raise RuntimeError(msg) + scripts = project.get("scripts") or {} + if not scripts: + msg = f"{pyproject} has no [project.scripts] — cannot derive entry" + raise RuntimeError(msg) + entry = next(iter(scripts)) + server = entry[: -len("-mcp")] if entry.endswith("-mcp") else str(project["name"]) + return server, entry + + +def build_local_spec(repo: pathlib.Path, entry: str) -> McpServerSpec: + """Build the ``uv --directory run `` spec used by ``use-local``.""" + return McpServerSpec( + command="uv", + args=["--directory", str(repo.resolve()), "run", entry], + ) + + +# --------------------------------------------------------------------------- +# State file +# --------------------------------------------------------------------------- + + +def load_state() -> dict[tuple[CLIName, Scope], SwapEntry]: + """Read the swap-state file, returning an empty mapping when absent. + + The state file's schema is internal — no compatibility contract — + so this loader assumes a single canonical shape. Malformed keys + (those that don't parse as ``cli:scope``) and entries with a + non-coercible ``seq_no`` or missing required fields are dropped + silently so a hand-edited file cannot crash the script. + """ + if not STATE_FILE.exists(): + return {} + raw = json.loads(STATE_FILE.read_text()) + entries = raw.get("entries", {}) + out: dict[tuple[CLIName, Scope], SwapEntry] = {} + for k, v in entries.items(): + parsed = _parse_state_key(k) + if parsed is None: + continue + entry = _parse_state_entry(v) + if entry is None: + continue + out[parsed] = entry + return out + + +def save_state(entries: dict[tuple[CLIName, Scope], SwapEntry]) -> None: + """Write the swap-state file atomically.""" + STATE_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "entries": { + _state_key(cli, scope): dataclasses.asdict(v) + for (cli, scope), v in entries.items() + }, + } + atomic_write(STATE_FILE, (json.dumps(payload, indent=2) + "\n").encode("utf-8")) + + +def clear_state(keys: t.Iterable[tuple[CLIName, Scope]]) -> None: + """Remove the given ``(cli, scope)`` keys; delete the file if empty.""" + current = load_state() + for key in keys: + current.pop(key, None) + if current: + save_state(current) + elif STATE_FILE.exists(): + STATE_FILE.unlink() + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class Presence: + """Detection outcome for a CLI: binary on PATH and config file present.""" + + cli: CLIName + binary_found: bool + config_found: bool + + @property + def present(self) -> bool: + """Return True only when both the binary and the config file were found.""" + return self.binary_found and self.config_found + + +def detect_clis() -> list[Presence]: + """Probe all supported CLIs and return their detection results.""" + return [ + Presence( + cli=info.name, + binary_found=shutil.which(info.binary) is not None, + config_found=info.config_path.exists(), + ) + for info in CLIS.values() + ] + + +def present_clis() -> list[CLIName]: + """Return the list of CLIs that have both a binary and a config present.""" + return [p.cli for p in detect_clis() if p.present] + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +def cmd_detect(args: argparse.Namespace) -> int: + """Print detection results for every supported CLI.""" + for p in detect_clis(): + flag = "yes" if p.present else " no" + extra = [] + if not p.binary_found: + extra.append("binary missing") + if not p.config_found: + extra.append(f"config missing: {CLIS[p.cli].config_path}") + suffix = f" ({', '.join(extra)})" if extra else "" + print(f" [{flag}] {p.cli:<7}{suffix}") + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + """Print the current MCP server entry per detected CLI. + + For Claude, prints separate lines for the user-level fallback + (``[claude:user]``) and the per-project override + (``[claude:project]``) when both exist; if only one exists, only + that line shows. ``args.scope`` (when set) restricts Claude output + to the matching layer only. Other CLIs print a single line as + ``[]`` since their config has no scope concept and ignore + ``args.scope``. + """ + repo = pathlib.Path(args.repo).resolve() + server = args.server or resolve_repo_meta(repo)[0] + scope_filter: Scope | None = args.scope + for cli in args.cli or present_clis(): + info = CLIS[cli] + if not info.config_path.exists(): + print(f"[{cli}] (no config at {info.config_path})") + continue + # Wrap the read + shape-guarded queries in try/except RuntimeError + # so a malformed Claude config surfaces as a clean per-CLI error + # instead of aborting status output for the rest of the CLIs. + try: + config = load_config(info) + if cli == "claude": + # Lazy reads: skip the get_server call entirely for the + # filtered-out scope so a malformed projects node doesn't + # raise when the user only asked about user scope. + user_spec = ( + get_server(cli, config, server, repo, scope="user") + if scope_filter in (None, "user") + else None + ) + project_spec = ( + get_server(cli, config, server, repo, scope="project") + if scope_filter in (None, "project") + else None + ) + shown = False + if user_spec is not None: + tag = _describe_spec(user_spec, repo) + print( + f"[claude:user] {server} = {user_spec.command} " + f"{' '.join(user_spec.args)} ({tag})" + ) + shown = True + if project_spec is not None: + tag = _describe_spec(project_spec, repo) + print( + f"[claude:project] {server} = {project_spec.command} " + f"{' '.join(project_spec.args)} ({tag})" + ) + shown = True + if not shown: + label = f"claude:{scope_filter}" if scope_filter else "claude" + print(f"[{label}] no entry for {server!r}") + else: + spec = get_server(cli, config, server, repo) + if spec is None: + print(f"[{cli}] no entry for {server!r}") + continue + tag = _describe_spec(spec, repo) + print( + f"[{cli}] {server} = {spec.command} {' '.join(spec.args)} ({tag})" + ) + except RuntimeError as exc: + print(f"[{cli}] {exc}", file=sys.stderr) + continue + return 0 + + +def _describe_spec(spec: McpServerSpec, repo: pathlib.Path) -> str: + """Return a short label classifying a spec (local/pypi-pin/other).""" + if spec.is_local_uv_directory(): + local = spec.local_repo_path() + if local and local.resolve() == repo.resolve(): + return "local: this repo" + return f"local: {local}" + if spec.command == "uvx": + pinned = next((a for a in spec.args if "==" in a or "@" in a), None) + return f"pypi pin: {pinned}" if pinned else "pypi (unpinned)" + return "other" + + +def cmd_use_local(args: argparse.Namespace) -> int: + """Rewrite each target CLI's config to run the repo's checkout via ``uv``. + + The optional ``--scope`` flag selects Claude's user-level fallback + vs. per-project override; see :data:`Scope`. The flag is silently + coerced to ``"user"`` for non-Claude CLIs by :func:`_normalize_scope`. + """ + repo = pathlib.Path(args.repo).resolve() + server, default_entry = resolve_repo_meta(repo) + server = args.server or server + entry = args.entry or default_entry + spec = build_local_spec(repo, entry) + + targets = args.cli or present_clis() + if not targets: + print("no CLIs detected — nothing to do", file=sys.stderr) + return 1 + + ts = time.strftime("%Y%m%d%H%M%S") + state = load_state() + had_error = 0 + for cli in targets: + scope = _normalize_scope(cli, args.scope) + label = f"{cli}:{scope}" if cli == "claude" else cli + info = CLIS[cli] + if not info.config_path.exists(): + print(f"[{label}] skip — config not found at {info.config_path}") + continue + # Wrap the read + shape-guarded mutation in try/except RuntimeError + # so a malformed Claude config (top-level mcpServers / projects not a + # mapping) surfaces as a clean per-CLI error instead of an uncaught + # traceback. Same per-CLI continuation pattern the inner write-failure + # handler below uses. + try: + original_bytes = info.config_path.read_bytes() + config = load_config(info) + current = get_server(cli, config, server, repo, scope=scope) + if ( + current + and current.is_local_uv_directory() + and current.local_repo_path() == repo + ): + print(f"[{label}] already local (this repo) — no change") + continue + # Preserve the existing entry's env on replacement. ``build_local_spec`` + # writes an empty env, so without this merge a swap would silently drop + # client-side settings (LIBTMUX_SAFETY, LIBTMUX_SOCKET, custom dev + # knobs). Symmetric with ``_spec_from_entry`` which round-trips env on + # the read side. + cli_spec = ( + dataclasses.replace(spec, env={**current.env}) if current else spec + ) + action = set_server(cli, config, server, cli_spec, repo, scope=scope) + new_bytes = dump_config_bytes(info, config) + except RuntimeError as exc: + print(f"[{label}] {exc}", file=sys.stderr) + had_error = 1 + continue + + if args.dry_run: + print(f"--- {info.config_path} (current)") + print(f"+++ {info.config_path} (proposed)") + diff = difflib.unified_diff( + original_bytes.decode(errors="replace").splitlines(keepends=True), + new_bytes.decode(errors="replace").splitlines(keepends=True), + lineterm="", + ) + sys.stdout.writelines(diff) + continue + + # Claude is the only CLI where two swaps (different scopes) can + # touch the same config file in one second; embed the scope so + # the second backup doesn't overwrite the first. Non-Claude + # backup filenames carry no scope suffix. + backup_suffix = f"{BACKUP_SUFFIX_PREFIX}{ts}" + if cli == "claude": + backup_suffix += f"-{scope}" + backup_path = info.config_path.with_suffix( + info.config_path.suffix + backup_suffix + ) + backup_path.write_bytes(original_bytes) + try: + atomic_write(info.config_path, new_bytes) + _revalidate(info) + except Exception as exc: + atomic_write(info.config_path, original_bytes) + print( + f"[{label}] write failed ({exc}); backup at {backup_path}", + file=sys.stderr, + ) + had_error = 1 + continue + next_seq = max((e.seq_no for e in state.values()), default=-1) + 1 + state[(cli, scope)] = SwapEntry( + config_path=str(info.config_path), + backup_path=str(backup_path), + server=server, + action=action, + swapped_at=ts, + seq_no=next_seq, + ) + print(f"[{label}] {action}; backup: {backup_path}") + + if not args.dry_run: + save_state(state) + return had_error + + +def _revalidate(info: CLIInfo) -> None: + """Re-parse the file after writing; raise on failure.""" + load_config(info) + + +def cmd_revert(args: argparse.Namespace) -> int: + """Restore each target CLI's config from the backup recorded in the state file. + + Without ``--scope``, every recorded entry for the targeted CLIs is + reverted (so a Claude install that has both user-scope and + project-scope swaps gets both restored). With ``--scope``, only + the matching scope is reverted; the parameter is silently coerced + to ``"user"`` for non-Claude CLIs. + """ + state = load_state() + # Without --cli, revert every CLI that has any recorded swap. + targets = list(args.cli) if args.cli else list({cli for cli, _scope in state}) + if not targets: + print("no recorded swaps — nothing to revert", file=sys.stderr) + return 1 + + reverted: list[tuple[CLIName, Scope]] = [] + for cli in targets: + if args.scope is not None: + wanted_scopes: tuple[Scope, ...] = (_normalize_scope(cli, args.scope),) + else: + wanted_scopes = ALL_SCOPES + cli_keys = [ + (sc_cli, sc_scope) + for (sc_cli, sc_scope) in state + if sc_cli == cli and sc_scope in wanted_scopes + ] + if not cli_keys: + label = f"{cli}:{args.scope}" if args.scope and cli == "claude" else cli + print(f"[{label}] no state entry — skip") + continue + # Unwind in reverse-registration order (LIFO) — sort by the + # explicit ``SwapEntry.seq_no`` counter so order is independent + # of JSON parse order, dict iteration, and wall-clock + # collisions. ``seq_no`` is coerced to ``int`` at load time by + # ``_parse_state_entry``; entries with a non-coercible value + # are dropped before they reach this sort, so the comparison + # is always int vs int. When two scopes back the same physical + # file (Claude user + project), the later swap's backup + # contains the earlier swap's modifications, so each backup + # must restore its own layer before the prior one is restored. + # Same explicit counter pattern CPython's ``Lib/sched.py`` uses + # to break ties on ``Event(time, priority, sequence, …)``. + cli_keys.sort(key=lambda k: state[k].seq_no, reverse=True) + for key in cli_keys: + sc_cli, sc_scope = key + entry = state[key] + label = f"{sc_cli}:{sc_scope}" if sc_cli == "claude" else sc_cli + backup = pathlib.Path(entry.backup_path) + dest = pathlib.Path(entry.config_path) + if not backup.exists(): + print(f"[{label}] backup missing: {backup}", file=sys.stderr) + continue + if args.dry_run: + print(f"[{label}] would restore {dest} from {backup}") + continue + atomic_write(dest, backup.read_bytes()) + # Backup served its purpose; LIFO unwind for this layer is + # complete. Delete on success, keep on error — same idiom + # CPython's ``tempfile.NamedTemporaryFile`` uses + # (Lib/tempfile.py:614-618). If ``atomic_write`` had raised, + # this line wouldn't run and the backup would survive for + # post-mortem; on success the backup is redundant and would + # otherwise accumulate forever across swap/revert cycles. + backup.unlink() + print(f"[{label}] restored from {backup}") + reverted.append(key) + + if not args.dry_run and reverted: + clear_state(reverted) + return 0 + + +# --------------------------------------------------------------------------- +# argparse glue +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + """Construct the ``argparse`` parser for ``mcp_swap``.""" + p = argparse.ArgumentParser(prog="mcp_swap", description=__doc__.splitlines()[0]) + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser( + "detect", help="list installed CLIs and their config presence" + ).set_defaults(func=cmd_detect) + + ps = sub.add_parser("status", help="show the current MCP server entry per CLI") + ps.add_argument("--repo", default=".", help="repo root (default: .)") + ps.add_argument( + "--server", help="MCP server name (default: derived from pyproject.toml)" + ) + ps.add_argument( + "--cli", action="append", choices=ALL_CLIS, help="limit to one or more CLIs" + ) + ps.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Limit Claude output to one scope: 'user' shows only the " + "top-level mcpServers fallback, 'project' shows only the " + "projects..mcpServers entry. Without this flag, both " + "Claude scopes print when both have an entry. No-op for " + "non-Claude CLIs (their config has no per-project layer)." + ), + ) + ps.set_defaults(func=cmd_status) + + pu = sub.add_parser("use-local", help="rewrite configs to run this checkout") + pu.add_argument("--repo", default=".", help="repo root (default: .)") + pu.add_argument( + "--server", help="MCP server name (default: derived from pyproject.toml)" + ) + pu.add_argument( + "--entry", help="uv run entry command (default: [project.scripts] first key)" + ) + pu.add_argument("--cli", action="append", choices=ALL_CLIS) + pu.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Claude config scope: 'user' rewrites the top-level mcpServers " + "fallback (every project without an override picks it up), " + "'project' rewrites projects..mcpServers under this repo. " + "Default 'project'. Silently coerced to 'user' for non-Claude CLIs." + ), + ) + pu.add_argument("--dry-run", action="store_true") + pu.set_defaults(func=cmd_use_local) + + pr = sub.add_parser("revert", help="restore each CLI's config from its swap backup") + pr.add_argument("--cli", action="append", choices=ALL_CLIS) + pr.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Limit revert to one Claude scope. Without this flag, every " + "recorded scope for the targeted CLIs is reverted." + ), + ) + pr.add_argument("--dry-run", action="store_true") + pr.set_defaults(func=cmd_revert) + + return p + + +def main(argv: list[str] | None = None) -> int: + """Entry point — dispatches to the selected subcommand.""" + args = build_parser().parse_args(argv) + return t.cast("int", args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/libtmux/experimental/__init__.py b/src/libtmux/experimental/__init__.py new file mode 100644 index 000000000..1bcc80a96 --- /dev/null +++ b/src/libtmux/experimental/__init__.py @@ -0,0 +1,19 @@ +"""Experimental libtmux APIs. + +This package hosts work that is **not** covered by the project's versioning +policy. Anything under :mod:`libtmux.experimental` may change shape or be +removed between any two releases without notice. + +Current contents: + +- :mod:`libtmux.experimental.ops` -- inert, typed tmux *operation* values: the + pure source of truth that renders tmux commands, carries result types, and + serializes without a live tmux server. +- :mod:`libtmux.experimental.engines` -- *engine* protocols and + implementations that execute operations and return typed results. + +See the operationalization plan (``tmux-python/libtmux`` issue 689) and the +architecture proposal (issue 688) for background. +""" + +from __future__ import annotations diff --git a/src/libtmux/experimental/_wait_pane.py b/src/libtmux/experimental/_wait_pane.py new file mode 100644 index 000000000..8e6465002 --- /dev/null +++ b/src/libtmux/experimental/_wait_pane.py @@ -0,0 +1,99 @@ +"""Wait for a freshly created pane's shell to draw its prompt. + +tmux returns a pane id the instant it forks the pane, long before the shell in it +has printed a prompt -- so a ``send-keys`` fired immediately can be swallowed. The +fix both builders use is the same: poll ``#{cursor_x},#{cursor_y}`` until the +cursor leaves the origin, then proceed. + +This is a *host* step: it must run between tmux dispatches, never inside a fold, +so both drivers replay it from their plan's ``on_step`` hook (the workspace runner +through a compiled :class:`~.workspace.compiler.HostStep`, the fluent builder +through its recorded host action). Only the poll itself is shared here. +""" + +from __future__ import annotations + +import asyncio +import time +import typing as t + +from libtmux.experimental.ops import DisplayMessage, arun, run +from libtmux.experimental.ops.plan import _resolve + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.operation import Operation + +#: Pane-readiness poll budget: ~2s at a 50ms cadence (matches tmuxp's timeout). +WAIT_PANE_POLLS = 40 +WAIT_PANE_INTERVAL = 0.05 +CURSOR_FMT = "#{cursor_x},#{cursor_y}" + + +def pane_ready(cursor: str) -> bool: + """Whether the pane's cursor has left the origin (its shell prompt drew). + + Parameters + ---------- + cursor : str + A ``#{cursor_x},#{cursor_y}`` reading, or ``""`` when unreadable. + + Returns + ------- + bool + + Examples + -------- + >>> pane_ready("2,1") + True + >>> pane_ready("0,0") + False + >>> pane_ready("") + False + """ + return bool(cursor) and cursor != "0,0" + + +def _cursor_probe( + pane: Target, + bindings: dict[int | tuple[int, str], str], +) -> Operation[t.Any]: + """Build the cursor read for *pane*, resolving a forward ref against bindings.""" + return _resolve(DisplayMessage(target=pane, message=CURSOR_FMT), bindings) + + +def wait_pane( + engine: TmuxEngine, + pane: Target, + bindings: dict[int | tuple[int, str], str], + version: str | None = None, +) -> bool: + """Poll *pane* until its prompt draws; return whether it did within the budget. + + Returns ``False`` on exhaustion rather than raising: a pane whose shell never + moves the cursor (a full-screen program, a bare ``cat``) is not an error, and + the build proceeds exactly as it did before the wait was requested. + """ + op = _cursor_probe(pane, bindings) + for _ in range(WAIT_PANE_POLLS): + if pane_ready(run(op, engine, version=version).text): + return True + time.sleep(WAIT_PANE_INTERVAL) + return False + + +async def await_pane( + engine: AsyncTmuxEngine, + pane: Target, + bindings: dict[int | tuple[int, str], str], + version: str | None = None, +) -> bool: + """Async sibling of :func:`wait_pane` (same budget, same exhaustion contract).""" + op = _cursor_probe(pane, bindings) + for _ in range(WAIT_PANE_POLLS): + result = await arun(op, engine, version=version) + if pane_ready(result.text): + return True + await asyncio.sleep(WAIT_PANE_INTERVAL) + return False diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py new file mode 100644 index 000000000..0d197334f --- /dev/null +++ b/src/libtmux/experimental/engines/__init__.py @@ -0,0 +1,66 @@ +"""Execution engines for :mod:`libtmux.experimental.ops`. + +An *engine* executes a rendered tmux command and returns a structured result. +Engines are interchangeable behind the :class:`~.base.TmuxEngine` / +:class:`~.base.AsyncTmuxEngine` protocols, so the same typed operation can run +through a subprocess (classic), an in-memory simulator (mock), a persistent +``tmux -C`` control connection, an async transport, or (as an easter egg) tmux's +native binary peer protocol -- and return the *same* typed result. + +See the operationalization plan (``tmux-python/libtmux`` issue 689). +""" + +from __future__ import annotations + +from libtmux.experimental.engines.async_control_mode import ( + AsyncControlModeEngine, + ControlNotification, +) +from libtmux.experimental.engines.asyncio import AsyncSubprocessEngine +from libtmux.experimental.engines.base import ( + AsyncTmuxEngine, + CommandRequest, + CommandResult, + EngineKind, + EngineSpec, + SupportsTmuxVersion, + TmuxEngine, +) +from libtmux.experimental.engines.connection import ServerConnection +from libtmux.experimental.engines.control_mode import ( + ControlModeEngine, + ControlModeError, + ControlModeParser, +) +from libtmux.experimental.engines.imsg import ImsgEngine +from libtmux.experimental.engines.mock import AsyncMockEngine, MockEngine +from libtmux.experimental.engines.registry import ( + available_engines, + create_engine, + register_engine, +) +from libtmux.experimental.engines.subprocess import SubprocessEngine + +__all__ = ( + "AsyncControlModeEngine", + "AsyncMockEngine", + "AsyncSubprocessEngine", + "AsyncTmuxEngine", + "CommandRequest", + "CommandResult", + "ControlModeEngine", + "ControlModeError", + "ControlModeParser", + "ControlNotification", + "EngineKind", + "EngineSpec", + "ImsgEngine", + "MockEngine", + "ServerConnection", + "SubprocessEngine", + "SupportsTmuxVersion", + "TmuxEngine", + "available_engines", + "create_engine", + "register_engine", +) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py new file mode 100644 index 000000000..ff7716555 --- /dev/null +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -0,0 +1,847 @@ +"""An asynchronous control-mode (``tmux -C``) engine with an event stream. + +A real async control engine -- not an ``asyncio.to_thread`` wrapper around the +sync one. It holds a persistent ``tmux -C`` connection, reads it from a single +background task, correlates each command to an :class:`asyncio.Future`, and +exposes tmux's asynchronous notifications (``%output``, ``%window-add``, ...) as +an ``async for`` event stream. + +Design, informed by prior libtmux/mux control-mode work: + +- The I/O-free :class:`~.control_mode.ControlModeParser` is reused verbatim; only + the I/O layer differs from the sync engine (``await stdout.read`` instead of + ``selectors``). +- Command correlation is a FIFO of futures resolved in block-arrival order. A + block that arrives with *no* pending command is **unsolicited** (a hook- + triggered command, or the startup ACK) and is skipped, so correlation never + desyncs. The startup ACK is consumed synchronously in :meth:`_spawn` before + the reader runs, closing the startup race. +- A supervisor owns the process lifecycle. :meth:`start` launches it once; it + spawns ``tmux -C``, replays the desired subscriptions, and runs the reader + inline (one reader at a time). When the reader returns on EOF, the supervisor + resets connection-scoped state -- a fresh parser, failed pending commands, + cleared attach -- bumps the connection generation, and reconnects with a + deterministic jittered backoff, so a tmux restart or socket blip self-heals + instead of freezing the engine. An intentional :meth:`aclose` flags + ``_closing`` first so the close is not mistaken for a crash and retried. +- A reader failure or EOF marks the engine *dead* and fails every pending + command, rather than hanging; the supervisor then reconnects. +- Notifications go to a bounded queue; on overflow the oldest is dropped and + counted (backpressure), mirroring control mode's own ``%pause`` philosophy. +""" + +from __future__ import annotations + +import asyncio +import collections +import contextlib +import logging +import typing as t +from dataclasses import dataclass, field + +from libtmux import exc +from libtmux.experimental.engines.base import render_control_line +from libtmux.experimental.engines.connection import ServerConnection +from libtmux.experimental.engines.control_mode import ( + BlockSequenceMonitor, + ControlModeError, + ControlModeParser, + _merge_blocks, + command_count, +) + +if t.TYPE_CHECKING: + import types + from collections.abc import AsyncIterator, Sequence + + from libtmux.experimental.engines.base import CommandRequest, CommandResult + from libtmux.experimental.engines.control_mode import ControlModeBlock + +logger = logging.getLogger(__name__) + +_READ_CHUNK = 65536 +_DEFAULT_TIMEOUT = 30.0 +_STARTUP_TIMEOUT = 5.0 +_STOP_TIMEOUT = 2.0 +# A connection must survive at least this long to count as healthy and reset the +# reconnect backoff; a shorter-lived one is treated as a failed attempt so a +# persistently flapping proc escalates instead of fork-storming. +_HEALTHY_CONNECTION_SECONDS = 1.0 + +_STREAM_END = object() # broadcast to subscriber queues to end their async for + + +@dataclass(frozen=True) +class ControlNotification: + """An asynchronous tmux control-mode notification. + + Examples + -------- + >>> ControlNotification.parse(b"%window-add @3") + ControlNotification(kind='window-add', args=('@3',), raw='%window-add @3') + >>> ControlNotification.parse(b"%output %1 hello world").kind + 'output' + """ + + kind: str + args: tuple[str, ...] + raw: str + + @classmethod + def parse(cls, line: bytes) -> ControlNotification: + """Parse a raw ``%``-notification line.""" + text = line.decode(errors="replace") + body = text[1:] if text.startswith("%") else text + parts = body.split(" ") + kind = parts[0] if parts else "" + return cls(kind=kind, args=tuple(parts[1:]), raw=text) + + +@dataclass(slots=True) +class _PendingCommand: + future: asyncio.Future[CommandResult] + argv: tuple[str, ...] + expected: int + blocks: list[ControlModeBlock] = field(default_factory=list) + + +def _offer( + queue: asyncio.Queue[ControlNotification], + notification: ControlNotification, +) -> int: + """Put *notification* on *queue*, dropping the oldest on overflow. + + Returns ``1`` when a notification was dropped, else ``0`` (so a broadcast can + tally drops without a ``try``/``except`` in its hot loop). + """ + try: + queue.put_nowait(notification) + except asyncio.QueueFull: + with contextlib.suppress(asyncio.QueueEmpty): + queue.get_nowait() + with contextlib.suppress(asyncio.QueueFull): + queue.put_nowait(notification) + return 1 + return 0 + + +def _force_put(queue: asyncio.Queue[t.Any], item: t.Any) -> None: + """Put *item* on *queue*, evicting the oldest entry first when it is full. + + Like :func:`_offer` but drop-count-free: used to land the stream-end + sentinel even on a queue already at ``maxsize``, so a slow consumer that hit + backpressure still gets closed instead of hanging on ``queue.get()``. Pulled + out of the broadcast loop so the ``try``/``except`` stays out of it. + """ + try: + queue.put_nowait(item) + except asyncio.QueueFull: + with contextlib.suppress(asyncio.QueueEmpty): + queue.get_nowait() # evict oldest; tolerable at death + with contextlib.suppress(asyncio.QueueFull): + queue.put_nowait(item) + + +def _swallow_future(future: asyncio.Future[t.Any]) -> None: + """Retrieve a fire-and-forget future's outcome so it isn't flagged unretrieved. + + Subscription-replay commands are dispatched without an awaiter; their futures + resolve in the reader. Calling :meth:`asyncio.Future.exception` marks the + result retrieved so a tmux-side ``%error`` (or a reconnect that fails the + pending command) never surfaces as a noisy "exception was never retrieved" + warning. + """ + if future.cancelled(): + return + with contextlib.suppress(Exception): + future.exception() + + +class AsyncControlModeEngine: + """Execute tmux commands over one persistent async ``tmux -C`` connection. + + Parameters + ---------- + tmux_bin : str or None + The tmux binary; resolved from ``$PATH`` when ``None``. + server_args : Sequence[str] + Connection flags inserted before ``-C``. + timeout : float + Seconds to await a command's result before failing it. + event_queue_size : int + Bounded size of the notification queue (backpressure). + + Notes + ----- + The connection opens lazily on first use. Use the engine as an async context + manager, or call :meth:`aclose`, to tear it down. + """ + + def __init__( + self, + tmux_bin: str | None = None, + *, + server_args: Sequence[str] = (), + timeout: float = _DEFAULT_TIMEOUT, + event_queue_size: int = 4096, + ) -> None: + self._conn = ServerConnection.of(tmux_bin, server_args) + self.timeout = timeout + self._parser = ControlModeParser() + self._sequence = BlockSequenceMonitor() + self._pending: collections.deque[_PendingCommand] = collections.deque() + self._event_queue_size = event_queue_size + self._subscribers: set[asyncio.Queue[t.Any]] = set() + self._dropped_notifications = 0 + self._proc: asyncio.subprocess.Process | None = None + self._start_lock = asyncio.Lock() + self._write_lock = asyncio.Lock() + self._started = False + self._dead: BaseException | None = None + # Desired (declarative) state, replayed on every (re)connect. + self._desired_subscriptions: list[str] = [] + self._desired_attach: list[str] = [] + self._attached_session: str | None = None + # Supervisor / reconnect bookkeeping. + self._generation = 0 + self._closing = False + self._supervisor_task: asyncio.Task[None] | None = None + self._connected = asyncio.Event() + self._spawn_error: BaseException | None = None + + @property + def connection(self) -> ServerConnection: + """The tmux binary + connection flags this engine dispatches through.""" + return self._conn + + @property + def tmux_bin(self) -> str | None: + """The explicitly configured tmux binary, if any.""" + return self._conn.tmux_bin + + @property + def server_args(self) -> tuple[str, ...]: + """Connection flags placed before ``-C``.""" + return self._conn.args + + def tmux_version(self) -> str | None: + """Report the connected server's tmux version (``tmux -V``), memoized. + + Implements + :class:`~libtmux.experimental.engines.base.SupportsTmuxVersion` so + version-gated operations render correctly over control mode; in-memory + engines omit it and resolution assumes latest. + """ + return self._conn.tmux_version() + + def add_subscription(self, spec: str) -> None: + """Record a desired ``refresh-client -B`` subscription (idempotent). + + The spec is stored in :attr:`_desired_subscriptions` and replayed on + every (re)connect by the supervisor, so a subscription survives a tmux + restart or socket blip. Adding the same spec twice is a no-op. + + Parameters + ---------- + spec : str + A ``refresh-client -B`` subscription spec, e.g. + ``"agentstate:%*:#{@agent_state}"``. + + Examples + -------- + >>> engine = AsyncControlModeEngine() + >>> engine.add_subscription("agentstate:%*:#{@agent_state}") + >>> engine.add_subscription("agentstate:%*:#{@agent_state}") + >>> engine._desired_subscriptions + ['agentstate:%*:#{@agent_state}'] + """ + if spec not in self._desired_subscriptions: + self._desired_subscriptions.append(spec) + + def set_attach_targets(self, ids: list[str]) -> None: + """Record the sessions the engine should (re)attach to on reconnect. + + Stores a *copy* of *ids* in :attr:`_desired_attach`. The supervisor + replays these on every (re)connect via :meth:`_replay_attach`, so the + engine stays attached across a tmux restart or socket blip (a control + client attaches to one session at a time, so the last target wins). + + Parameters + ---------- + ids : list[str] + Session ids to attach to (e.g. ``["$0", "$1"]``). + + Examples + -------- + >>> engine = AsyncControlModeEngine() + >>> engine.set_attach_targets(["$0", "$1"]) + >>> engine._desired_attach + ['$0', '$1'] + """ + self._desired_attach = list(ids) + + async def start(self) -> None: + """Launch the supervisor (once) and wait for its first connection. + + The supervisor owns the ``tmux -C`` process lifecycle: it spawns the + proc, consumes the startup ACK, replays desired subscriptions, runs the + reader, and reconnects with backoff when the reader returns. This method + is idempotent (the ``_start_lock`` + ``_started`` guard) and never + launches a second supervisor; all callers block until the first + connection is established. + """ + async with self._start_lock: + if not self._started: + self._closing = False + self._spawn_error = None + self._connected.clear() + self._supervisor_task = asyncio.create_task( + self._supervisor(), + name="libtmux-async-control-supervisor", + ) + self._started = True + # Block (every caller) until the supervisor's first connect resolves. + if self._supervisor_task is not None: + await self._connected.wait() + if self._spawn_error is not None: + # Keep _spawn_error set here: a *concurrent* start() caller from + # the same failed first connect must also observe it and raise, + # not see a nulled error and return "success" against a dead + # engine. The error is cleared only by the fresh-start reset + # above (the `if not self._started` block) when a NEW attempt + # begins, so every waiter from this failed connect raises + # consistently. + error = self._spawn_error + async with self._start_lock: + self._started = False + self._supervisor_task = None + raise error + + async def _spawn(self) -> None: + """Spawn a fresh ``tmux -C`` process and consume its startup ACK. + + Extracted from :meth:`start` so the supervisor can re-run it on every + reconnect. Sets :attr:`_proc`, then clears :attr:`_dead` only *after* the + startup ACK is consumed (so a command racing the reconnect still hits the + dead-guard). The caller is responsible for resetting the parser *before* + this runs, so the new process's startup bytes are parsed by a fresh parser. + """ + # A reader that returned via an exception (not a clean EOF) leaves the + # prior tmux -C alive; terminate it before overwriting _proc so a + # reconnect never orphans a control client. A clean-EOF proc has already + # exited, so this is a no-op there. + old = self._proc + if old is not None and old.returncode is None: + with contextlib.suppress(ProcessLookupError): + old.terminate() + cmd = self._conn.argv("-C") + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + self._proc = proc + # Keep the death-sentinel set while the startup ACK is consumed: across a + # reconnect ``_connected`` stays set, so a concurrent ``run_batch`` would + # otherwise pass the dead-guard, write to the new proc, and have its reply + # DRAINED+DISCARDED by ``_consume_startup`` (its future then times out and + # its stale pending entry desyncs FIFO). Clearing ``_dead`` only after the + # ACK is consumed makes such a racing command hit the dead-guard instead. + await self._consume_startup() + self._dead = None + + async def _consume_startup(self) -> None: + """Read and discard tmux's startup ACK block before commands flow. + + Doing this synchronously (before the reader task launches and before any + command future is queued) means the startup block can never be matched + to a real command. + """ + proc = self._proc + if proc is None or proc.stdout is None: + return + loop = asyncio.get_running_loop() + deadline = loop.time() + _STARTUP_TIMEOUT + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return + try: + chunk = await asyncio.wait_for( + proc.stdout.read(_READ_CHUNK), + timeout=remaining, + ) + except asyncio.TimeoutError: + return + if not chunk: + return + self._parser.feed(chunk) + self._parser.notifications() # discard any startup notifications + discarded = self._parser.blocks() + if discarded: # startup ACK seen and discarded + solicited = [block for block in discarded if block.flags == 1] + if solicited: + # Anything but the ACK here is a command reply thrown away on + # the reconnect path -- the documented swallow risk. + logger.warning( + "control-mode startup consumed %s solicited block(s); " + "%s command(s) were pending", + len(solicited), + len(self._pending), + extra={ + "tmux_stdout": [ + f"#{block.number}: " + + b" | ".join(block.body).decode(errors="replace") + for block in solicited + ], + "tmux_stdout_len": len(solicited), + }, + ) + return + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command over the control connection.""" + return (await self.run_batch([request]))[0] + + async def run_batch( + self, requests: Sequence[CommandRequest] + ) -> list[CommandResult]: + """Pipeline a batch of commands; one result per request, in order.""" + if not requests: + return [] + await self.start() + if self._dead is not None: + msg = "control-mode engine is dead" + raise ControlModeError(msg) from self._dead + + loop = asyncio.get_running_loop() + rendered = [tuple(req.args) for req in requests] + futures: list[asyncio.Future[CommandResult]] = [] + async with self._write_lock: + proc = self._proc + if proc is None or proc.stdin is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + appended: list[_PendingCommand] = [] + for argv in rendered: + future: asyncio.Future[CommandResult] = loop.create_future() + pending = _PendingCommand(future, argv, command_count(argv)) + self._pending.append(pending) + appended.append(pending) + futures.append(future) + payload = b"".join( + (render_control_line(argv) + "\n").encode() for argv in rendered + ) + try: + proc.stdin.write(payload) + await proc.stdin.drain() + except (BrokenPipeError, OSError) as error: + # Remove the futures we just queued so a write failure cannot + # leave orphans that desync FIFO correlation for the next batch. + cm_error = ControlModeError(f"tmux control-mode write failed: {error}") + for queued in appended: + with contextlib.suppress(ValueError): + self._pending.remove(queued) + if not queued.future.done(): + queued.future.set_exception(cm_error) + raise cm_error from error + + try: + return await asyncio.wait_for( + asyncio.gather(*futures), + timeout=self.timeout, + ) + except asyncio.TimeoutError as error: + # The futures stay queued (now cancelled); the reader drains their + # blocks on arrival, keeping FIFO correlation aligned. + msg = f"tmux control-mode timed out after {self.timeout}s" + raise ControlModeError(msg) from error + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Yield asynchronous tmux notifications as they arrive. + + Each subscriber gets its own queue, so concurrent subscribers (the event + push tool, the pull ring, the output monitor) each see *every* + notification rather than competing for one shared stream. The iterator + runs until the engine is closed or the caller stops iterating; its queue + is unregistered on exit. When the engine dies, ``_STREAM_END`` is + broadcast to every subscriber queue so the ``async for`` ends cleanly + instead of hanging on ``queue.get()``. + + A subscribe() *after* :meth:`aclose` (which set :attr:`_closing`, + broadcast the stream-end sentinel, and cleared :attr:`_subscribers`) + would register a fresh queue no broadcast will ever touch, hanging the + consumer forever. So a permanently-closing engine yields nothing and + ends at once. A merely :attr:`_dead` (reconnecting) engine keeps the + subscriber, so the post-reconnect reader feeds it. + """ + if self._closing: + return + queue: asyncio.Queue[t.Any] = asyncio.Queue( + maxsize=self._event_queue_size, + ) + self._subscribers.add(queue) + try: + while True: + item = await queue.get() + if item is _STREAM_END: + return + yield item + finally: + self._subscribers.discard(queue) + + @property + def dropped_notifications(self) -> int: + """How many notifications were dropped due to a full event queue.""" + return self._dropped_notifications + + async def aclose(self) -> None: + """Tear down: flag closing, cancel the supervisor, fail pending, kill proc. + + Setting :attr:`_closing` *first* distinguishes an intentional close from a + crash, so cancelling the supervisor (and the reader it owns inline) ends + the loop instead of triggering a reconnect. + """ + if not self._started: + return + self._closing = True + self._started = False + supervisor = self._supervisor_task + self._supervisor_task = None + if supervisor is not None: + supervisor.cancel() + with contextlib.suppress(asyncio.CancelledError): + await supervisor + # Release any start() still blocked on the first connection. The + # supervisor's finally covers a normal exit, but a task cancelled before + # it ever runs never reaches that finally, so set it here too. + self._connected.set() + self._broadcast_stream_end() + self._fail_pending(ControlModeError("control-mode engine closed")) + proc = self._proc + self._proc = None + if proc is not None and proc.returncode is None: + with contextlib.suppress(ProcessLookupError): + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=_STOP_TIMEOUT) + except asyncio.TimeoutError: + with contextlib.suppress(ProcessLookupError): + proc.kill() + await proc.wait() + + async def __aenter__(self) -> AsyncControlModeEngine: + """Start the engine on context entry.""" + await self.start() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Close the engine on context exit.""" + await self.aclose() + + async def _supervisor(self) -> None: + """Own the proc lifecycle: connect, replay desired state, read, reconnect. + + One supervisor runs at a time (launched once by :meth:`start`). Each + iteration resets connection-scoped state *before* the new process's bytes + flow -- a fresh :class:`~.control_mode.ControlModeParser`, failed pending + commands, cleared attach -- then spawns ``tmux -C``, bumps + :attr:`_generation`, replays subscriptions, and runs the reader inline so + there is never more than one reader. When the reader returns on EOF (and + the engine is not :attr:`_closing`), it backs off with deterministic + jitter and reconnects. An intentional :meth:`aclose` cancels this task, + which propagates into the inline reader. + """ + attempt = 0 + connected_once = False + try: + while not self._closing: + # Reset connection-scoped state BEFORE the new proc's bytes flow. + # Reconnect is the only place permitted to reset the parser and + # fail pending, keeping FIFO correlation aligned across the gap. + self._parser = ControlModeParser() + self._sequence.reset() + self._fail_pending(ControlModeError("control-mode reconnecting")) + self._reset_attach() + try: + await self._spawn() + except asyncio.CancelledError: + raise + except BaseException as error: + if not connected_once: + # First connect failed (e.g. missing binary): surface it + # to start() and stop -- a permanent error should not spin. + self._spawn_error = error + return + # A transient spawn failure mid-life: back off and retry. + await asyncio.sleep(self._backoff(attempt)) + attempt += 1 + continue + # The spawn succeeded and its startup ACK was consumed. Do NOT + # reset the backoff yet: a proc that connects then immediately + # dies (reader EOF within the grace) is not a healthy session, and + # resetting here would pin every reconnect at _backoff(0) and + # fork-storm tmux. The reset is gated on connection lifetime below. + self._generation += 1 + connected_once = True + await self._reap_own_session() + await self._replay_subscriptions() + await self._replay_attach() + self._connected.set() # first connect done: unblock start() + # The reader runs inline (one reader at a time). On EOF it returns + # and we reconnect; on cancellation (aclose) it propagates out. + loop = asyncio.get_running_loop() + connected_at = loop.time() + await self._reader() + if self._closing: + return + # Only a connection that survived a meaningful interval resets the + # backoff; a connect-then-immediately-die counts as a failed + # attempt, so a persistently flapping proc escalates instead of + # spinning at _backoff(0). + if loop.time() - connected_at >= _HEALTHY_CONNECTION_SECONDS: + attempt = 0 + await asyncio.sleep(self._backoff(attempt)) + attempt += 1 + finally: + # Always release start() waiters -- even on cancel/return before the + # first connect -- so an aclose() racing a start() can never leave a + # waiter blocked on _connected.wait() forever. + self._connected.set() + + async def _reap_own_session(self) -> None: + """Mark this control client's throwaway session ``destroy-unattached``. + + A bare ``tmux -C`` connect implies ``new-session``, so each connection + spawns a phantom session on the target server. Right after connect -- while + the control client is still attached to that just-created session, before + :meth:`_replay_attach` moves it -- this sets ``destroy-unattached on`` on + the *current* session (the phantom; no ``-t`` and no ``-g``, so it is scoped + to exactly that session, never global). tmux then reaps the phantom the + moment the client attaches elsewhere or disconnects, so control-mode never + litters the server with throwaway sessions and a reconnect storm cannot pile + them up. Fire-and-forget, like :meth:`_replay_subscriptions` -- the result + block is swallowed since the reader has not started yet. + """ + proc = self._proc + if proc is None or proc.stdin is None: + return + loop = asyncio.get_running_loop() + argv = ("set-option", "destroy-unattached", "on") + async with self._write_lock: + future: asyncio.Future[CommandResult] = loop.create_future() + future.add_done_callback(_swallow_future) + self._pending.append(_PendingCommand(future, argv, command_count(argv))) + try: + proc.stdin.write((render_control_line(argv) + "\n").encode()) + await proc.stdin.drain() + except (BrokenPipeError, OSError): + # The proc died before the option landed; the reader will EOF and + # the supervisor reconnects (the next connect re-marks its phantom). + return + + async def _replay_subscriptions(self) -> None: + """Re-issue every desired subscription to the freshly connected proc. + + Each spec is sent as ``refresh-client -B `` with a queued pending + command, so the reader correlates its result block in FIFO order (the + replay commands sit at the front of the deque, ahead of any user command, + because :meth:`start` has not yet returned). The futures are + fire-and-forget: their outcome is swallowed rather than awaited, since the + reader has not started yet. Writing here re-enters neither :meth:`start` + nor :meth:`run_batch`, so the supervisor cannot recurse into itself. + """ + if not self._desired_subscriptions: + return + proc = self._proc + if proc is None or proc.stdin is None: + return + loop = asyncio.get_running_loop() + async with self._write_lock: + payload_parts: list[bytes] = [] + for spec in self._desired_subscriptions: + argv = ("refresh-client", "-B", spec) + future: asyncio.Future[CommandResult] = loop.create_future() + future.add_done_callback(_swallow_future) + self._pending.append(_PendingCommand(future, argv, command_count(argv))) + payload_parts.append((render_control_line(argv) + "\n").encode()) + try: + proc.stdin.write(b"".join(payload_parts)) + await proc.stdin.drain() + except (BrokenPipeError, OSError): + # The proc died before replay landed; the reader will EOF and the + # supervisor reconnects, failing these pending commands then. + return + + async def _replay_attach(self) -> None: + """Re-attach to every desired session on the freshly connected proc. + + Mirrors :meth:`_replay_subscriptions`. A fresh ``tmux -C`` process is + attached to nothing, and per-pane ``%subscription-changed`` only flows + to an *attached* control client, so the supervisor must re-attach after + every (re)connect or a monitor that relies on the option channel goes + silent. Each target is written as ``attach-session -t `` directly + to stdin with a swallowed pending future (same FIFO + :attr:`_write_lock` + discipline as the subscription replay), so it re-enters neither + :meth:`start` nor :meth:`run_batch`. A control client attaches to one + session at a time, so the last target wins. The attach is fire-and-forget, + so it does not cache :attr:`_attached_session` here; the events layer sets + that on a confirmed attach and re-attaches on a miss. Does nothing when + :attr:`_desired_attach` is empty. + """ + if not self._desired_attach: + return + proc = self._proc + if proc is None or proc.stdin is None: + return + loop = asyncio.get_running_loop() + async with self._write_lock: + payload_parts: list[bytes] = [] + for target in self._desired_attach: + argv = ("attach-session", "-t", target) + future: asyncio.Future[CommandResult] = loop.create_future() + future.add_done_callback(_swallow_future) + self._pending.append(_PendingCommand(future, argv, command_count(argv))) + payload_parts.append((render_control_line(argv) + "\n").encode()) + try: + proc.stdin.write(b"".join(payload_parts)) + await proc.stdin.drain() + except (BrokenPipeError, OSError): + # The proc died before replay landed; the reader will EOF and the + # supervisor reconnects, failing these pending commands then. + return + # The attach is fire-and-forget (swallowed future): its returncode is + # not awaited, so _attached_session is NOT cached optimistically here. + # The events layer caches it only on a confirmed attach and re-attaches + # on a miss, so a session that vanished during the disconnect surfaces a + # real error instead of a silently-empty capture. + + def _reset_attach(self) -> None: + """Clear the sticky attach so reconnect re-attaches from scratch. + + The events layer caches which session this engine attached to in + :attr:`_attached_session`; a fresh process is attached to nothing, so the + cache must be cleared on every (re)connect. + """ + self._attached_session = None + + @staticmethod + def _backoff(attempt: int) -> float: + """Deterministic jittered exponential backoff (seconds) for *attempt*. + + Capped exponential (``min(0.1 * 2**attempt, 5.0)``) plus a small jitter + derived solely from *attempt* -- never :mod:`random` or wall-clock time -- + so reconnect timing stays reproducible under test. + """ + base = min(0.1 * (2.0**attempt), 5.0) + jitter = 0.01 * float(attempt % 7) + return base + jitter + + async def _reader(self) -> None: + """Background task: read tmux output, resolve futures, publish events.""" + proc = self._proc + if proc is None or proc.stdout is None: + return + stdout = proc.stdout + try: + while True: + chunk = await stdout.read(_READ_CHUNK) + if not chunk: + self._mark_dead(ControlModeError("tmux -C closed stdout")) + return + self._parser.feed(chunk) + for block in self._parser.blocks(): + self._dispatch_block(block) + for line in self._parser.notifications(): + self._publish(line) + except asyncio.CancelledError: + raise + except Exception as error: + self._mark_dead(ControlModeError(f"control-mode reader failed: {error}")) + + def _dispatch_block(self, block: ControlModeBlock) -> None: + """Accumulate a solicited block; resolve the command once it has them all. + + A ``;``-folded command emits one block per sub-command; unsolicited blocks + (hook-triggered commands, the startup ACK) carry flags 0 and are skipped, + so FIFO correlation never desyncs. + """ + if block.flags != 1: + return # unsolicited (hook-triggered command or startup ACK): skip + if not self._pending: + # A solicited reply with no command waiting: its command's future was + # already resolved, cancelled, or failed. FIFO is now one block off. + logger.warning( + "control-mode dropped solicited block #%s with no pending command", + block.number, + extra={ + "tmux_stdout": [ + line.decode(errors="replace") for line in block.body + ], + "tmux_stdout_len": len(block.body), + }, + ) + return + pending = self._pending[0] + self._sequence.check(block, pending.argv) + pending.blocks.append(block) + if len(pending.blocks) < pending.expected: + return + self._pending.popleft() + if not pending.future.done(): + pending.future.set_result(_merge_blocks(pending.blocks, pending.argv)) + + def _publish(self, line: bytes) -> None: + """Broadcast a notification to every subscriber (drop-oldest per queue). + + Runs synchronously from the single reader task, so the subscriber set is + never mutated mid-iteration. + """ + notification = ControlNotification.parse(line) + for queue in self._subscribers: + self._dropped_notifications += _offer(queue, notification) + + def _broadcast_stream_end(self) -> None: + """Push the stream-end sentinel to every subscriber, then clear them. + + Uses :func:`_force_put` so the sentinel lands even on a queue already at + ``maxsize`` (a slow consumer that hit backpressure); otherwise the + sentinel would be lost and the consumer would hang forever on + ``queue.get()`` -- the exact bug this guards against. + """ + for queue in list(self._subscribers): + _force_put(queue, _STREAM_END) + self._subscribers.clear() + + def _mark_dead(self, error: BaseException) -> None: + """Record the engine as dead and fail all pending commands.""" + if self._dead is None: + self._dead = error + self._fail_pending(error) + self._broadcast_stream_end() + + def _fail_pending(self, error: BaseException) -> None: + """Fail every queued command future with *error*.""" + while self._pending: + pending = self._pending.popleft() + if not pending.future.done(): + pending.future.set_exception(error) + + @classmethod + def for_server(cls, server: t.Any, **kwargs: t.Any) -> AsyncControlModeEngine: + """Build an async control-mode engine bound to a live server's socket.""" + conn = ServerConnection.from_server(server) + return cls( + tmux_bin=conn.tmux_bin, + server_args=conn.args, + **kwargs, + ) diff --git a/src/libtmux/experimental/engines/asyncio.py b/src/libtmux/experimental/engines/asyncio.py new file mode 100644 index 000000000..69206db89 --- /dev/null +++ b/src/libtmux/experimental/engines/asyncio.py @@ -0,0 +1,128 @@ +"""A real asynchronous subprocess engine. + +Built on :func:`asyncio.create_subprocess_exec` -- genuine async process I/O, +not a thread wrapper around the sync engine. On cancellation it terminates the +child process before propagating :class:`asyncio.CancelledError`, so a cancelled +``arun`` leaks no tmux process. It mirrors the classic engine's output handling +(``backslashreplace`` decoding, trailing-blank stripping) so it returns the +*same* typed result the classic engine does. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandResult +from libtmux.experimental.engines.connection import ServerConnection + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class AsyncSubprocessEngine: + """Execute tmux commands via :func:`asyncio.create_subprocess_exec`. + + Parameters + ---------- + tmux_bin : str or pathlib.Path or None + The tmux binary; resolved from ``$PATH`` when ``None``. + server_args : Sequence[str] + Connection flags inserted before the command. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.ops import SendKeys, arun + >>> from libtmux.experimental.ops._types import PaneId + >>> engine = AsyncSubprocessEngine() + >>> hasattr(engine, "run") and hasattr(engine, "run_batch") + True + """ + + def __init__( + self, + tmux_bin: str | pathlib.Path | None = None, + *, + server_args: Sequence[str] = (), + ) -> None: + self._conn = ServerConnection.of(tmux_bin, server_args) + + @property + def connection(self) -> ServerConnection: + """The tmux binary + connection flags this engine dispatches through.""" + return self._conn + + @property + def tmux_bin(self) -> str | None: + """The explicitly configured tmux binary, if any.""" + return self._conn.tmux_bin + + @property + def server_args(self) -> tuple[str, ...]: + """Connection flags placed before every tmux subcommand.""" + return self._conn.args + + def tmux_version(self) -> str | None: + """Report this engine's tmux version (``tmux -V``), memoized. + + Returns ``None`` when the binary is missing or its version cannot be + parsed, so version resolution degrades to "assume latest". + """ + return self._conn.tmux_version() + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command asynchronously and return its result.""" + cmd = self._conn.argv(*request.args, tmux_bin=request.tmux_bin) + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + + try: + stdout_bytes, stderr_bytes = await process.communicate() + except asyncio.CancelledError: + # The child may have already exited (terminate races the reap); + # suppress so the cancellation propagates, not ProcessLookupError. + with contextlib.suppress(ProcessLookupError): + process.terminate() + await process.wait() + raise + + stdout = stdout_bytes.decode(errors="backslashreplace") + stderr = stderr_bytes.decode(errors="backslashreplace") + + stdout_lines = stdout.split("\n") + while stdout_lines and stdout_lines[-1] == "": + stdout_lines.pop() + stderr_lines = [line for line in stderr.split("\n") if line] + + return CommandResult( + cmd=tuple(cmd), + stdout=tuple(stdout_lines), + stderr=tuple(stderr_lines), + returncode=process.returncode if process.returncode is not None else -1, + ) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute requests sequentially (preserving tmux command ordering).""" + return [await self.run(req) for req in requests] + + @classmethod + def for_server(cls, server: t.Any) -> AsyncSubprocessEngine: + """Build an async engine bound to a live :class:`libtmux.Server`'s socket.""" + conn = ServerConnection.from_server(server) + return cls(tmux_bin=conn.tmux_bin, server_args=conn.args) diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py new file mode 100644 index 000000000..a04cf08b2 --- /dev/null +++ b/src/libtmux/experimental/engines/base.py @@ -0,0 +1,244 @@ +"""Core engine abstractions: requests, results, and the engine protocols. + +A :class:`CommandRequest` is a rendered tmux argv plus an optional binary path; a +:class:`CommandResult` is the structured outcome. :class:`TmuxEngine` and +:class:`AsyncTmuxEngine` are :class:`typing.Protocol` types, so any object with +the right methods is an engine -- including a live :class:`libtmux.Server` for +the classic case -- without inheriting a base class. +""" + +from __future__ import annotations + +import enum +import re +import shlex +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + +#: tmux escapes a byte in ``%output`` as a backslash plus three octal digits. +_CONTROL_OCTAL = re.compile(rb"\\([0-7]{3})") + + +def render_control_line(argv: Sequence[str]) -> str: + """Render a tmux argv as a control-mode (``tmux -C``) command line. + + Each token is quoted for the control parser, but a standalone ``;`` separator + is left bare so a folded ``a ; b`` chain dispatches as two commands instead of + one command with a literal ``';'`` argument. + + Examples + -------- + >>> render_control_line(("rename-window", "-t", "@1", "a b")) + "rename-window -t @1 'a b'" + >>> render_control_line(("rename-window", "a", ";", "kill-window", "@2")) + 'rename-window a ; kill-window @2' + """ + return " ".join(token if token == ";" else shlex.quote(token) for token in argv) + + +def unescape_control_output(payload: str) -> bytes: + r"""Decode a control-mode ``%output`` payload back to the bytes the pane wrote. + + tmux does not forward pane output verbatim: in a ``%output`` notification it + writes every non-printable byte -- and the backslash itself -- as a backslash + followed by three octal digits. A reader that scans for raw bytes must undo + this first, or it can never match: an ``ESC`` (``0x1b``) arrives on the wire + as the four *characters* ``\``, ``0``, ``3``, ``3``. + + Bytes tmux left alone pass through untouched, so feeding this an already-raw + payload is harmless. + + Examples + -------- + Printable output is returned as-is: + + >>> unescape_control_output("hello world") + b'hello world' + + An escape sequence tmux octal-escaped comes back as real bytes: + + >>> unescape_control_output(r"\033]3008;state=idle\033\134") + b'\x1b]3008;state=idle\x1b\\' + + Multi-byte UTF-8 survives the round trip: + + >>> unescape_control_output(r"caf\303\251").decode() + 'café' + """ + raw = payload.encode("utf-8", "surrogateescape") + return _CONTROL_OCTAL.sub(lambda m: bytes((int(m.group(1), 8),)), raw) + + +@dataclass(frozen=True) +class CommandRequest: + """A rendered tmux command, ready for an engine to execute. + + Parameters + ---------- + args : tuple[str, ...] + The tmux argv *after* the binary (e.g. ``("split-window", "-t", "%1")``). + tmux_bin : str or None + Override the tmux binary for this request; ``None`` lets the engine + decide. + + Examples + -------- + >>> CommandRequest.from_args("split-window", "-t", "%1") + CommandRequest(args=('split-window', '-t', '%1'), tmux_bin=None) + >>> CommandRequest.from_args("kill-window", "-t", 2).args + ('kill-window', '-t', '2') + """ + + args: tuple[str, ...] + tmux_bin: str | None = None + + @classmethod + def from_args( + cls, + *args: t.Any, + tmux_bin: str | pathlib.Path | None = None, + ) -> CommandRequest: + """Build a request from arbitrary tokens, stringifying each.""" + return cls( + args=tuple(map(str, args)), + tmux_bin=str(tmux_bin) if tmux_bin is not None else None, + ) + + +@dataclass(frozen=True) +class CommandResult: + """The structured outcome of executing a :class:`CommandRequest`. + + A tmux-side failure (``%error`` / nonzero exit) is *data* here -- it sets + ``returncode`` and ``stderr`` rather than raising. Only engine-broken + conditions (missing binary, lost connection, protocol desync) raise. + + Parameters + ---------- + cmd : tuple[str, ...] + The full argv that ran (including the tmux binary). + stdout, stderr : tuple[str, ...] + Captured output lines. + returncode : int + tmux exit code (``-1`` when unknown). + """ + + cmd: tuple[str, ...] + stdout: tuple[str, ...] = () + stderr: tuple[str, ...] = () + returncode: int = 0 + + +class EngineKind(str, enum.Enum): + """Named engine families.""" + + SUBPROCESS = "subprocess" + MOCK = "mock" + CONTROL_MODE = "control_mode" + IMSG = "imsg" + + +@dataclass(frozen=True) +class EngineSpec: + """A typed, serializable selector for an engine family. + + Examples + -------- + >>> EngineSpec.subprocess().kind + + >>> EngineSpec.imsg(protocol_version=8).protocol_version + 8 + >>> EngineSpec.subprocess(protocol_version=8) + Traceback (most recent call last): + ... + ValueError: protocol_version is only valid for the imsg engine + """ + + kind: EngineKind + protocol_version: int | None = None + + def __post_init__(self) -> None: + """Normalize and validate the spec.""" + kind = EngineKind(self.kind) + if kind is not EngineKind.IMSG and self.protocol_version is not None: + msg = "protocol_version is only valid for the imsg engine" + raise ValueError(msg) + object.__setattr__(self, "kind", kind) + + @classmethod + def subprocess(cls, *, protocol_version: int | None = None) -> EngineSpec: + """Build a subprocess (classic) engine spec.""" + return cls(kind=EngineKind.SUBPROCESS, protocol_version=protocol_version) + + @classmethod + def mock(cls) -> EngineSpec: + """Build a mock (in-memory) engine spec.""" + return cls(kind=EngineKind.MOCK) + + @classmethod + def control_mode(cls) -> EngineSpec: + """Build a control-mode engine spec.""" + return cls(kind=EngineKind.CONTROL_MODE) + + @classmethod + def imsg(cls, *, protocol_version: int | None = None) -> EngineSpec: + """Build an imsg (native binary) engine spec.""" + return cls(kind=EngineKind.IMSG, protocol_version=protocol_version) + + +@t.runtime_checkable +class TmuxEngine(t.Protocol): + """A synchronous executor of tmux commands.""" + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command and return its structured result.""" + ... + + def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute requests in order, returning one result per request. + + Persistent-connection engines (control mode) override this to pipeline; + stateless engines implement it as a loop over :meth:`run`. + """ + ... + + +@t.runtime_checkable +class AsyncTmuxEngine(t.Protocol): + """An asynchronous executor of tmux commands.""" + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command and return its structured result.""" + ... + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute requests in order, returning one result per request.""" + ... + + +@t.runtime_checkable +class SupportsTmuxVersion(t.Protocol): + """An engine that can report the tmux version it targets. + + Optional engine capability. The executors + (:func:`~libtmux.experimental.ops.execute.run` / ``arun`` and the + :class:`~libtmux.experimental.ops.plan.LazyPlan` drivers) call + :meth:`tmux_version` to resolve the version for version-gated rendering when + the caller passes none. Engines that cannot know their version -- in-memory + or fake engines -- simply do not implement it, and resolution falls back to + "assume latest". + """ + + def tmux_version(self) -> str | None: + """Return the engine's tmux version string, or ``None`` if unknown.""" + ... diff --git a/src/libtmux/experimental/engines/connection.py b/src/libtmux/experimental/engines/connection.py new file mode 100644 index 000000000..8968b71f8 --- /dev/null +++ b/src/libtmux/experimental/engines/connection.py @@ -0,0 +1,216 @@ +"""The connection an engine talks to: which tmux binary, which tmux server. + +Every real engine -- subprocess, asyncio, control mode, async control mode, imsg +-- needs the same two things before it can dispatch anything: a tmux *binary* to +exec, and the *connection flags* (``-L``/``-S``/``-f``/``-2``/``-8``) that point +at one particular tmux server. :class:`ServerConnection` is that pair, as one +frozen value object, and it is the *only* place either is computed. + +Engines **hold** a connection (``self._conn``) rather than re-deriving one. +:meth:`ServerConnection.resolve_bin` is the single door to a tmux binary path: +it memoizes :func:`shutil.which` and raises +:exc:`~libtmux.exc.TmuxCommandNotFound` when tmux is absent, so an engine cannot +accidentally ship an unguarded, unmemoized ``shutil.which("tmux")`` of its own. +:meth:`ServerConnection.tmux_version` is the matching memoized ``tmux -V`` probe. +""" + +from __future__ import annotations + +import shutil +import typing as t +from dataclasses import dataclass, field + +from libtmux import exc +from libtmux.common import get_version + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + +class _BinaryResolver: + """Memoized tmux-binary resolution and ``tmux -V`` probe. + + Owned by a :class:`ServerConnection`; never constructed by engines. Holding + the (mutable) cache here keeps :class:`ServerConnection` itself a frozen, + comparable value. + """ + + __slots__ = ("_declared", "_resolved", "_version", "_version_probed") + + def __init__(self, tmux_bin: str | None = None) -> None: + self._declared = tmux_bin + self._resolved: str | None = None + self._version: str | None = None + self._version_probed = False + + def resolve(self) -> str: + """Return the tmux binary path, memoized for this connection. + + An explicit binary wins. Otherwise :func:`shutil.which` walks ``$PATH`` + once -- it costs ~50µs and sits on the hot path of every command -- and + the answer is cached. A *failure* is not cached, so a tmux installed + after the miss is picked up. + """ + if self._declared is not None: + return self._declared + if self._resolved is None: + resolved = shutil.which("tmux") + if resolved is None: + raise exc.TmuxCommandNotFound + self._resolved = resolved + return self._resolved + + def version(self) -> str | None: + """Return the tmux version string, memoized; ``None`` when unknowable. + + ``None`` (missing binary, unparseable output) lets version resolution + degrade to "assume latest" rather than exploding. + """ + if not self._version_probed: + self._version_probed = True + try: + self._version = str(get_version(self.resolve())) + except exc.LibTmuxException: + self._version = None + return self._version + + +@dataclass(frozen=True) +class ServerConnection: + """Which tmux binary, and which tmux server, an engine talks to. + + Parameters + ---------- + tmux_bin : str or None + An explicit tmux binary. ``None`` means "resolve from ``$PATH``", which + :meth:`resolve_bin` does once and memoizes. + args : tuple[str, ...] + Connection flags placed before the tmux subcommand (e.g. ``("-Lwork",)``). + + Examples + -------- + The default connection targets the ambient tmux server: + + >>> ServerConnection() + ServerConnection(tmux_bin=None, args=()) + + :meth:`from_server` reads the flags off a live :class:`libtmux.Server`, + which is what every engine's ``for_server`` classmethod is built on: + + >>> conn = ServerConnection.from_server(server) + >>> conn.args[0].startswith(("-L", "-S")) + True + >>> conn.tmux_version() == conn.tmux_version() # memoized + True + + It duck-types, so a plain object with the same attributes works too: + + >>> import types + >>> ServerConnection.from_server( + ... types.SimpleNamespace(socket_name="work", colors=256) + ... ) + ServerConnection(tmux_bin=None, args=('-Lwork', '-2')) + + :meth:`argv` prepends the binary and the flags to a rendered command: + + >>> ServerConnection.of(tmux_bin="tmux", args=("-Lwork",)).argv( + ... "kill-window", "-t", "@1" + ... ) + ['tmux', '-Lwork', 'kill-window', '-t', '@1'] + """ + + tmux_bin: str | None = None + args: tuple[str, ...] = () + _resolver: _BinaryResolver = field( + init=False, + repr=False, + compare=False, + hash=False, + ) + + def __post_init__(self) -> None: + """Normalize *args* and build the connection's binary resolver.""" + object.__setattr__(self, "args", tuple(self.args)) + object.__setattr__(self, "_resolver", _BinaryResolver(self.tmux_bin)) + + @classmethod + def of( + cls, + tmux_bin: str | pathlib.Path | None = None, + args: Sequence[str] = (), + ) -> ServerConnection: + """Build a connection, stringifying a :class:`pathlib.Path` binary. + + Examples + -------- + >>> import pathlib + >>> ServerConnection.of(pathlib.Path("/usr/bin/tmux")).tmux_bin + '/usr/bin/tmux' + >>> ServerConnection.of(args=["-L", "test"]).args + ('-L', 'test') + """ + return cls( + tmux_bin=str(tmux_bin) if tmux_bin is not None else None, + args=tuple(args), + ) + + @classmethod + def from_server(cls, server: t.Any) -> ServerConnection: + """Build the connection a live :class:`libtmux.Server` talks over. + + Mirrors :meth:`libtmux.Server.cmd`'s connection-flag construction, so an + engine built from it reaches the same tmux server as the object API. + + Examples + -------- + >>> import types + >>> ServerConnection.from_server( + ... types.SimpleNamespace(socket_path="/tmp/s", config_file="/tmp/c") + ... ) + ServerConnection(tmux_bin=None, args=('-S/tmp/s', '-f/tmp/c')) + """ + args: list[str] = [] + if getattr(server, "socket_name", None): + args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + args.append("-2") + elif colors == 88: + args.append("-8") + return cls.of(tmux_bin=getattr(server, "tmux_bin", None), args=args) + + def resolve_bin(self) -> str: + """Return the tmux binary path (memoized). + + Raises :exc:`~libtmux.exc.TmuxCommandNotFound` when tmux is not on + ``$PATH`` and none was declared -- the guard every engine gets for free. + + Examples + -------- + >>> ServerConnection.of(tmux_bin="/usr/bin/tmux").resolve_bin() + '/usr/bin/tmux' + """ + return self._resolver.resolve() + + def tmux_version(self) -> str | None: + """Return this connection's tmux version (memoized), or ``None``. + + Examples + -------- + >>> ServerConnection().tmux_version() is not None + True + """ + return self._resolver.version() + + def argv(self, *args: str, tmux_bin: str | None = None) -> list[str]: + """Render a full command line: binary, connection flags, then *args*. + + *tmux_bin* overrides this connection's binary for one command (a + :class:`~libtmux.experimental.engines.base.CommandRequest` may carry one). + """ + return [tmux_bin or self.resolve_bin(), *self.args, *args] diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py new file mode 100644 index 000000000..0f19a6465 --- /dev/null +++ b/src/libtmux/experimental/engines/control_mode.py @@ -0,0 +1,676 @@ +"""A persistent control-mode (``tmux -C``) engine. + +Holds one long-lived ``tmux -C`` connection and pipelines command lines over it, +parsing each command's ``%begin``/``%end``/``%error`` block back into a +:class:`~.base.CommandResult`. Because it returns the same typed result the +subprocess engine does, an operation run through control mode is +indistinguishable -- at the result level -- from one run through a fork-per-call +subprocess. + +The parser (:class:`ControlModeParser`) is I/O-free: it consumes bytes and emits +parsed blocks, so it is unit-testable without spawning tmux. ``run_batch`` writes +all command lines at once and collects one block per command, which is the +control engine's advantage over per-call subprocess startup. +""" + +from __future__ import annotations + +import collections +import contextlib +import dataclasses +import logging +import os +import selectors +import subprocess +import threading +import time +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandResult, render_control_line +from libtmux.experimental.engines.connection import ServerConnection + +if t.TYPE_CHECKING: + import types + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + +logger = logging.getLogger(__name__) + +_BEGIN_PREFIX = b"%begin " +_END_PREFIX = b"%end " +_ERROR_PREFIX = b"%error " +_READ_CHUNK = 65536 +_DEFAULT_TIMEOUT = 30.0 +_STARTUP_TIMEOUT = 5.0 +_GRACEFUL_EXIT_TIMEOUT = 0.5 +_TERMINATE_TIMEOUT = 1.0 +_GUARD_MIN_PARTS = 3 + + +class ControlModeError(exc.LibTmuxException): + """The control-mode engine failed (connection, protocol, or timeout).""" + + +@dataclasses.dataclass(frozen=True, slots=True) +class ControlModeBlock: + """One ``%begin``/``%end`` or ``%error`` control-mode command block.""" + + number: int + flags: int + is_error: bool + body: tuple[bytes, ...] + + +@dataclasses.dataclass(slots=True) +class _PendingBlock: + number: int + flags: int + body: list[bytes] + + +class ControlModeParser: + r"""I/O-free parser for the command-block subset of control mode. + + Examples + -------- + >>> parser = ControlModeParser() + >>> parser.feed(b"%begin 1 1 1\nhello\n%end 1 1 1\n") + >>> [block.body for block in parser.blocks()] + [(b'hello',)] + >>> parser.feed(b"%begin 2 2 1\nboom\n%error 2 2 1\n") + >>> block = parser.blocks()[0] + >>> block.is_error, block.body + (True, (b'boom',)) + """ + + __slots__ = ("_blocks", "_buffer", "_notifications", "_pending") + + def __init__(self) -> None: + self._buffer = bytearray() + self._blocks: list[ControlModeBlock] = [] + self._notifications: list[bytes] = [] + self._pending: _PendingBlock | None = None + + def feed(self, data: bytes) -> None: + """Consume bytes from tmux stdout.""" + if not data: + return + self._buffer.extend(data) + while True: + newline = self._buffer.find(b"\n") + if newline < 0: + return + line = bytes(self._buffer[:newline]) + del self._buffer[: newline + 1] + self._handle_line(line) + + def blocks(self) -> list[ControlModeBlock]: + """Drain parsed command blocks.""" + blocks, self._blocks = self._blocks, [] + return blocks + + def notifications(self) -> list[bytes]: + """Drain raw ``%``-notification lines seen outside command blocks. + + Control mode wraps *command output* in ``%begin``/``%end`` blocks but + emits asynchronous notifications (``%output``, ``%window-add``, ...) as + bare lines. The sync engine ignores these; the async engine routes them + to its event stream. + """ + notifications, self._notifications = self._notifications, [] + return notifications + + def _handle_line(self, line: bytes) -> None: + if self._pending is not None: + if _matches_pending_close(line, self._pending.number): + self._close_block(line) + return + self._pending.body.append(line) + return + if line.startswith(_BEGIN_PREFIX): + self._open_block(line) + elif line.startswith(b"%"): + self._notifications.append(line) + + def _open_block(self, line: bytes) -> None: + number, flags = _parse_guard(line, _BEGIN_PREFIX) + if number is None: + # A %begin whose guard will not parse is dropped: its body lines then + # fall through as notifications and a LATER command's block fills the + # count, silently mis-attributing output. Never observed in practice + # (tmux always writes "%begin