Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
5c15ae4
ControlMode(core): Add engine stack and protocol bridge
tony Nov 24, 2025
e712f3e
Server(refactor[sessions]): Use engine internal filters
tony Nov 24, 2025
6fbfb7d
ControlMode(test): Add control-mode regression coverage and helpers
tony Nov 24, 2025
9a96e95
docs(control-mode): Document engine usage, errors, env reqs, sandbox
tony Nov 24, 2025
3f16f68
docs(control-mode): Note env requirements and capture normalization i…
tony Nov 24, 2025
7c89995
docs(control-mode): Restructure release notes with examples (#605)
tony Nov 24, 2025
16e2713
ControlMode(test): Cover protocol errors, attach_to, sandbox isolation
tony Nov 24, 2025
fe3a8dd
test(control-mode): Adjust attach_to expectation to ignore control-on…
tony Nov 24, 2025
35317d6
ControlMode(test): Add restart, overflow, attach failure, capture ran…
tony Nov 24, 2025
d1ec199
ControlMode(core,test): Preflight attach_to and harden restart/captur…
tony Nov 24, 2025
47559d0
ControlMode(test): Add attach failure, flags, and retry placeholder
tony Nov 24, 2025
31a670c
test(control-mode): Add attach notifications and capture flag coverage
tony Nov 24, 2025
bf88561
test(control-mode): Expand attach notifications, capture flags, retry…
tony Nov 24, 2025
ca75f95
ControlMode(core,test): Preflight attach_to and stabilize control fla…
tony Nov 24, 2025
c1a0e29
ControlMode(core,test): Attach preflight and stabilize control flag/c…
tony Nov 24, 2025
77792be
test(control-mode): Xfail capture range/joined races
tony Nov 24, 2025
7b923da
test(control-mode): Xfail restart retry and capture races
tony Nov 24, 2025
85c68b9
test(control-mode): Xfail notification, collision, switch-client gaps
tony Nov 24, 2025
159616b
test(control-mode): Add xfail coverage for backlog, notifications, co…
tony Nov 24, 2025
6f2bf46
ControlMode(test[xfail]): Add scrollback/timeout placeholders
tony Nov 24, 2025
99247dd
ControlModeEngine(feat[testability]): Allow injectable process factory
tony Nov 24, 2025
553cca6
ControlModeEngine(types): Introduce process protocol for test hooks
tony Nov 24, 2025
1106427
ControlModeEngine(types): Extend process protocol (pid/poll)
tony Nov 24, 2025
a9245a6
ControlModeEngine(types): Cast fakes to protocol in tests
tony Nov 24, 2025
1dc476e
ControlModeEngine(test): Make fakes satisfy _ControlProcess without c…
tony Nov 24, 2025
86e9c17
ControlModeEngine(feat[testability]): Configurable retries and thread…
tony Nov 24, 2025
272aee4
ControlModeEngine(test): Tighten scripted process typing for mypy
tony Nov 24, 2025
1c5ebea
ControlModeEngine(test): Backpressure integration via scripted process
tony Nov 24, 2025
2f93e69
ControlModeEngine(core): UUID session names, control-mode flag, drain…
tony Nov 25, 2025
2337938
ControlModeEngine(threading): Non-daemon threads with explicit join
tony Nov 25, 2025
16c0a09
ControlModeEngine(abstraction): Engine hooks for probe/switch
tony Nov 25, 2025
7dc94c5
neo: Route fetch_objs through engine for unified command execution
tony Nov 25, 2025
88a4d84
ControlModeEngine(test): ScriptedStdout/Stdin with queue-based I/O
tony Nov 25, 2025
eccf3b9
pane(fix): Unify capture_pane trimming with engine behavior
tony Nov 25, 2025
6c52f95
conftest(fix): Skip doctests for control mode engine
tony Nov 25, 2025
b41635a
conftest(fix): Filter doctests at collection for control engine
tony Nov 25, 2025
ff14d11
test(cleanup): Remove obsolete timeout restart xfail
tony Nov 25, 2025
c93d3cf
engine(refactor): Add ServerContext and Engine.bind()
tony Nov 25, 2025
f1350c9
engine(api): Standardize hook signatures using stored context
tony Nov 25, 2025
59c7f57
ControlModeEngine(api): Rename attach_to to control_session
tony Nov 25, 2025
281a5d7
ControlModeEngine(feat): Add set_client_flags() for runtime flag control
tony Nov 25, 2025
4cc4606
ControlProtocol(fix): Add SKIPPING state for unexpected %begin blocks
tony Dec 1, 2025
f0404d6
test(hooks): Mark raw_cmd and dataclass tests as subprocess-only
tony Dec 1, 2025
0998e67
test(options): Avoid % format expansion in control mode test
tony Dec 1, 2025
3555911
test(env): Use wait_for_line() for environment propagation tests
tony Dec 1, 2025
45298f1
ControlProtocol(feat): Add MESSAGE and CONFIG_ERROR notification types
tony Dec 2, 2025
fc9ac59
ControlProtocol(fix): Correct notification parsing per tmux source
tony Dec 2, 2025
a5f9a0b
ControlProtocol(test[lint-timing]): fix control-mode lint and timing
tony Dec 6, 2025
32521e5
Hooks(chore[compat]): drop tmux<3.2 guards
tony Dec 6, 2025
eb83aed
Pane(fix[capture]): trim trailing whitespace consistently
tony Dec 6, 2025
291be61
Server(test[connect]): cover connect() under control engine
tony Dec 6, 2025
7b6e48e
ControlModeEngine(test[flags]): verify set_client_flags invocation
tony Dec 6, 2025
f80d108
CI(chore[control]): add control-engine pytest run
tony Dec 6, 2025
10812c4
docs(control): add control-engine testing guidance
tony Dec 6, 2025
54c5b76
ControlModeEngine(test[typing]): fix lint typing for set_client_flags…
tony Dec 6, 2025
993fbf1
ControlModeEngine(feat[flags]): expand control client helpers
tony Dec 6, 2025
40f5c34
ControlModeEngine(test[flags]): cover client flags, flow, subscriptions
tony Dec 6, 2025
6fc1ca7
Docs(control): document client flags, flow, subscriptions
tony Dec 6, 2025
edaa750
ControlModeEngine(test[live]): exercise flags/flow/subscriptions
tony Dec 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ jobs:
COV_CORE_SOURCE: .
COV_CORE_CONFIG: .coveragerc
COV_CORE_DATAFILE: .coverage.eager
- name: Test with pytest (control engine)
continue-on-error: ${{ matrix.tmux-version == 'master' }}
run: |
sudo apt install libevent-2.1-7
export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH
tmux -V
uv run py.test --engine=control -n auto --verbose
- uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
Expand Down
55 changes: 55 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,61 @@ $ uvx --from 'libtmux' --prerelease allow python

_Upcoming changes will be written here._

### Features

#### Control mode engine (experimental) (#605)
- Control-mode–first engine stack with structured `CommandResult`, notification parsing
(%layout-change, unlinked window add/close/rename, client session change/detach, paste buffer),
and stats, while keeping the public `cmd` API returning `tmux_cmd`.
- Internal control session is automatically filtered from `Server.sessions` /
`Server.has_session()`; advanced users can inspect all sessions via
`Server._sessions_all()`.
- `ControlModeEngine` accepts `internal_session_name` (default `libtmux_control_mode`)
and `control_session` for advanced connection strategies.

Example:

```python
from libtmux._internal.engines.control_mode import ControlModeEngine
from libtmux.server import Server

engine = ControlModeEngine(command_timeout=5)
server = Server(engine=engine)
session = server.new_session(session_name="ctrl")
for notif in engine.iter_notifications(timeout=0.1):
print(notif.kind, notif.data)
```

#### Convenience APIs (#605)
- `Server.connect()` returns an existing session or creates a new detached one,
working with both subprocess and control-mode engines.

#### Diagnostics and resilience (#605)
- Bounded notification queue with drop counting; exposed via engine stats.
- Expanded exceptions: `ControlModeTimeout`, `ControlModeProtocolError`,
`ControlModeConnectionError`, `SubprocessTimeout`; documented retry/timeout behaviour.
- Control-mode capture-pane trims trailing whitespace-only lines to mirror subprocess
semantics; first capture after send-keys briefly retries to avoid prompt races.

#### Testing utilities (#605)
- Control sandbox fixture provides a hermetic control-mode tmux server (isolated HOME,
TMUX_TMPDIR, unique socket); handy for integration-style tests.
Example:

```python
@pytest.mark.engines(["control"])
def test_control_sandbox(control_sandbox):
with control_sandbox as server:
out = server.cmd("display-message", "-p", "hi")
assert out.stdout == ["hi"]
```

### Compatibility

- Control mode internal session filtering is engine-driven; callers no longer need
to manually exclude `libtmux_control_mode`. APIs stay unchanged; additional metadata
is attached for advanced users. (#605)

## libtmux 0.50.1 (2025-12-06)

### Documentation (#612)
Expand Down
143 changes: 143 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@

from __future__ import annotations

import contextlib
import pathlib
import shutil
import subprocess
import typing as t
import uuid

import pytest
from _pytest.doctest import DoctestItem

from libtmux._internal.engines.control_protocol import CommandContext, ControlProtocol
from libtmux.pane import Pane
from libtmux.pytest_plugin import USING_ZSH
from libtmux.server import Server
Expand Down Expand Up @@ -73,3 +78,141 @@ def setup_session(
"""Session-level test configuration for pytest."""
if USING_ZSH:
request.getfixturevalue("zshrc")


# ---------------------------------------------------------------------------
# Control-mode sandbox helper
# ---------------------------------------------------------------------------


@pytest.fixture
@contextlib.contextmanager
def control_sandbox(
monkeypatch: pytest.MonkeyPatch,
tmp_path_factory: pytest.TempPathFactory,
) -> t.Iterator[Server]:
"""Provide an isolated control-mode server for a test.

- Creates a unique tmux socket name per invocation
- Isolates HOME and TMUX_TMPDIR under a per-test temp directory
- Clears TMUX env var to avoid inheriting user sessions
- Uses ControlModeEngine; on exit, kills the server best-effort
"""
socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}"
base = tmp_path_factory.mktemp("ctrl_sandbox")
home = base / "home"
tmux_tmpdir = base / "tmux"
home.mkdir()
tmux_tmpdir.mkdir()

monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("TMUX_TMPDIR", str(tmux_tmpdir))
monkeypatch.delenv("TMUX", raising=False)

from libtmux._internal.engines.control_mode import ControlModeEngine

server = Server(socket_name=socket_name, engine=ControlModeEngine())

try:
yield server
finally:
with contextlib.suppress(Exception):
server.kill()


@pytest.fixture
def control_client_logs(
control_sandbox: t.ContextManager[Server],
tmp_path_factory: pytest.TempPathFactory,
) -> t.Iterator[tuple[subprocess.Popen[str], ControlProtocol]]:
"""Spawn a raw tmux -C client against the sandbox and log stdout/stderr."""
base = tmp_path_factory.mktemp("ctrl_logs")
stdout_path = base / "control_stdout.log"
stderr_path = base / "control_stderr.log"

with control_sandbox as server:
cmd = [
"tmux",
"-L",
server.socket_name or "",
"-C",
"attach-session",
"-t",
"ctrltest",
]
# Ensure ctrltest exists
server.cmd("new-session", "-d", "-s", "ctrltest")
stdout_path.open("w+", buffering=1)
stderr_f = stderr_path.open("w+", buffering=1)
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=stderr_f,
text=True,
bufsize=1,
)
proto = ControlProtocol()
# tmux -C will emit a %begin/%end pair for this initial attach-session;
# queue a matching context so the parser has a pending command.
proto.register_command(CommandContext(argv=list(cmd)))
try:
yield proc, proto
finally:
with contextlib.suppress(Exception):
if proc.stdin:
proc.stdin.write("kill-session -t ctrltest\n")
proc.stdin.flush()
proc.terminate()
proc.wait(timeout=2)


def pytest_addoption(parser: pytest.Parser) -> None:
"""Add CLI options for selecting tmux engine."""
parser.addoption(
"--engine",
action="store",
default="subprocess",
choices=["subprocess", "control"],
help="Select tmux engine for fixtures (default: subprocess).",
)


def pytest_configure(config: pytest.Config) -> None:
"""Register custom markers."""
config.addinivalue_line(
"markers",
(
"engines(names): run the test once for each engine in 'names' "
"(e.g. ['control', 'subprocess'])."
),
)


def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
"""Parametrize engine_name when requested by tests."""
if "engine_name" in metafunc.fixturenames:
marker = metafunc.definition.get_closest_marker("engines")
if marker:
params = list(marker.args[0])
else:
params = [metafunc.config.getoption("--engine")]
metafunc.parametrize("engine_name", params, indirect=True)


def pytest_collection_modifyitems(
config: pytest.Config,
items: list[pytest.Item],
) -> None:
"""Filter out doctests when running with control engine.

Remove doctests from collection to avoid pytest's _use_item_location
bug: DoctestItem.reportinfo() returns None lineno for fixture doctests,
which triggers assertion failure in _pytest/reports.py:420 when skipped.
"""
engine_opt = config.getoption("--engine", default="subprocess")
if engine_opt != "control":
return

# Filter out DoctestItems - can't use skip markers due to pytest bug
items[:] = [item for item in items if not isinstance(item, DoctestItem)]
6 changes: 6 additions & 0 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

# API Reference

:::{note}
Looking for the new control-mode engine? See {ref}`control-mode` for an
experimental, protocol-focused entry point that still preserves the public
``tmux_cmd`` return type.
:::

```{toctree}

properties
Expand Down
47 changes: 47 additions & 0 deletions docs/pytest-plugin/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,53 @@ def set_home(
monkeypatch.setenv("HOME", str(user_path))
```

## Selecting tmux engines (experimental)

Fixtures can run against different execution engines. By default the
`subprocess` engine is used. You can choose control mode globally:

```console
$ pytest --engine=control
```

Or per-test via the `engines` marker (uses parametrization) and the `engine_name`
fixture:

```python
import pytest

@pytest.mark.engines(["subprocess", "control"])
def test_my_flow(server, engine_name):
# server uses the selected engine, engine_name reflects the current one
assert engine_name in {"subprocess", "control"}
assert server.is_alive()
```

`TestServer` also respects the selected engine. Control mode is experimental and
its APIs may change between releases.

### Control sandbox fixture (experimental)

Use ``control_sandbox`` when you need a hermetic control-mode server for a test:

```python
import typing as t
import pytest
from libtmux.server import Server

@pytest.mark.engines(["control"])
def test_control_sandbox(control_sandbox: t.ContextManager[Server]):
with control_sandbox as server:
session = server.new_session(session_name="sandbox", attach=False)
out = server.cmd("display-message", "-p", "hi")
assert out.stdout == ["hi"]
```

The fixture:
- Spins up a unique socket name and isolates ``HOME`` / ``TMUX_TMPDIR``
- Clears inherited ``TMUX`` so it never attaches to the user's server
- Uses ``ControlModeEngine`` and cleans up the server on exit

## Fixtures

```{eval-rst}
Expand Down
52 changes: 52 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,23 @@ in your server object. `libtmux.Server(socket_name='mysocket')` is
equivalent to `$ tmux -L mysocket`.
:::

### Optional: Control mode (experimental)

Control mode keeps a persistent tmux client open and streams
notifications. Enable it by injecting a control-mode engine:

```python
from libtmux._internal.engines.control_mode import ControlModeEngine
from libtmux.server import Server

engine = ControlModeEngine()
server = Server(engine=engine)
session = server.new_session(session_name="ctrl")
print(session.name)
```

See {ref}`control-mode` for details, caveats, and notification handling.

`server` is now a living object bound to the tmux server's Sessions,
Windows and Panes.

Expand Down Expand Up @@ -263,6 +280,41 @@ Session($1 foo)

to give us a `session` object to play with.

## Connect to or create a session

A simpler approach is to use {meth}`Server.connect()`, which returns an existing
session or creates it if it doesn't exist:

```python
>>> session = server.connect('my_project')
>>> session.name
'my_project'

>>> # Calling again returns the same session
>>> session2 = server.connect('my_project')
>>> session2.session_id == session.session_id
True
```

This is particularly useful for:

- Development workflows where you want to reuse existing sessions
- Scripts that should create a session on first run, then reattach
- Working with both subprocess and control-mode engines transparently

Compare with the traditional approach:

```python
>>> # Traditional: check then create
>>> if server.has_session('my_project'):
... session = server.sessions.get(session_name='my_project')
... else:
... session = server.new_session('my_project')

>>> # Simpler with connect()
>>> session = server.connect('my_project')
```

## Playing with our tmux session

We now have access to `session` from above with all of the methods
Expand Down
Loading
Loading