Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
78 changes: 53 additions & 25 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,32 @@ _Notes on the upcoming release will go here._

### Breaking changes

#### Plain semicolon arguments are data (#739)

Tmux interprets an unescaped trailing `;` as command structure, including when
it is the final character of a larger value. Direct subprocess engines now
escape ordinary unescaped suffix semicolons so they round-trip literally and
cannot start a second command. An existing `\;` suffix remains tmux escape
syntax and is not escaped again. Code that intentionally grouped commands with
a plain `";"` argument must pass
{class}`~libtmux.engines.base.CommandSeparator` instead:

```python
# Before
server.cmd("display-message", "first", ";", "display-message", "second")

# After
from libtmux.engines import CommandSeparator

server.cmd(
"display-message",
"first",
CommandSeparator(";"),
"display-message",
"second",
)
```

#### `raise_if_dead()` no longer echoes tmux's error (#739)

{meth}`Server.raise_if_dead() <libtmux.Server.raise_if_dead>` previously let
Expand Down Expand Up @@ -75,7 +101,8 @@ loading.
Every tmux command libtmux runs now goes through an *engine* — an object that
takes a rendered argv and returns a structured result. The default,
{class}`~libtmux.engines.subprocess.SubprocessEngine`, forks the tmux binary
exactly as before, so existing code is unaffected.
once per request as before. Ordinary unescaped semicolon suffixes remain data;
explicit multi-command groups now use a typed separator.

Pass `engine=` to {class}`~libtmux.Server` and every command on that server runs
through your object instead. {class}`~libtmux.engines.base.TmuxEngine` is a
Expand All @@ -85,15 +112,14 @@ against a recorded or in-memory tmux with no server running, and it is the seam
the control-mode, asyncio, and native-protocol engines plug into.

This ships the seam only. {meth}`Server.cmd() <libtmux.Server.cmd>` still
returns a {class}`~libtmux.common.tmux_cmd`, arguments still reach tmux
unchanged, and nothing about the default path is new — an engine is the one
thing you can now replace.
returns a {class}`~libtmux.common.tmux_cmd`, and an engine is the one thing you
can now replace.

An engine that names no tmux server of its own adopts the server's connection,
so injecting one into a socket-scoped {class}`~libtmux.Server` cannot silently
dispatch to the ambient tmux server. Engines that name a server keep it. A
custom `tmux_bin` selects a program rather than a server, so an engine carrying
only one adopts the server's flags and keeps its own binary.
An inspectable engine must satisfy the explicit connection values on
{class}`~libtmux.Server`. Stateless engines safely adopt missing values; a
conflict or a pinned persistent connection fails before dispatch, so a Server
cannot silently operate on a different socket. Connectionless recording and
in-memory engines remain usable without inventing tmux-specific behavior.

#### Observing what an engine runs (#739)

Expand All @@ -116,7 +142,7 @@ own: a command group is one dispatch carrying several tmux commands, and a
literal `";"` a caller meant as data is not a boundary.

{class}`~libtmux.engines.connection.ServerConnection` is now the single place
the tmux binary and the `-L`/`-S`/`-f`/`-2`/`-8` flags are computed; three
the tmux binary and the `-L`/`-S`/`-f`/`-2` flags are computed; three
separate copies previously disagreed about which flags to emit. It is derived
from the server's public attributes on each use, so reassigning `socket_name`
takes effect on the next command, and it memoizes its {func}`shutil.which`
Expand All @@ -127,7 +153,7 @@ tmux version it targets via
{class}`~libtmux.engines.base.SupportsTmuxVersion` capability, which callers
rendering version-gated argv read to decide whether a flag is safe to send.

An engine that folds several commands into one dispatch needs to know which `;`
An engine that groups several commands into one request needs to know which `;`
in an argv is a boundary and which is data.
{class}`~libtmux.engines.base.CommandSeparator` marks the boundary and
{func}`~libtmux.engines.base.is_command_separator` finds it, so a `;` a caller
Expand Down Expand Up @@ -158,27 +184,28 @@ 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.
(sequential or ordered request batching), so dispatch strategies can be tested
against the same plan with identical per-operation results. A batching planner
never joins commands with `;` or borrows tmux's server-global marked pane.

#### Declarative workspace builds fold to a few tmux calls (#690)
#### Declarative workspace request batching (#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.
{meth}`~libtmux.experimental.workspace.ir.Workspace.abuild` batch ready requests
by default. Every operation retains its own request, output, failure, and typed
result. Persistent control transports can pipeline a batch; subprocess engines
still start one process per request.

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
sequential execution because captured identifiers and host-side steps
(per-command sleeps, the ``wait_pane`` anti-race, ``before_script``) remain hard
batch boundaries. Pass a
{class}`~libtmux.experimental.ops.planner.SequentialPlanner` to ``build`` for one
legible tmux call per operation when debugging.
legible engine call per operation when debugging.

#### Floating panes on tmux 3.7 (#690)

Expand All @@ -194,7 +221,7 @@ on a window, including a pane that overlays a different window.
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
respawn, clear history, kill) that run as an ordered request batch. A query
resolves against a live engine or a plain list of pane snapshots, so the same
code runs offline in tests.

Expand Down Expand Up @@ -226,8 +253,9 @@ command -- no sentinel string -- and whether its process exited.
queries behind {attr}`~libtmux.Server.sessions` built their own connection flags
and emitted only `-L`/`-S`, so a server constructed with `config_file=` or
`colors=` passed those flags on some commands and not others. All paths now
share one connection. A `colors=` value other than `256` or `88` raises
{exc}`~libtmux.exc.UnknownColorOption` on those paths as well.
share one connection. Only `colors=256` is supported; every other truthy value,
including the legacy `88` mode removed before the minimum supported tmux,
raises {exc}`~libtmux.exc.UnknownColorOption` on those paths as well.

### Documentation

Expand Down
2 changes: 1 addition & 1 deletion docs/_ext/tmuxop/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ def build_operation_description(
"Minimum tmux",
_literal_fact(entry.min_version or "any supported version"),
),
ApiFactRow("Chainable", _boolean_fact(entry.chainable)),
ApiFactRow("Batchable", _boolean_fact(entry.batchable)),
ApiFactRow(
"Version-gated flags",
build_chip_paragraph(
Expand Down
2 changes: 1 addition & 1 deletion docs/api/libtmux.engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ also implement.

A {class}`~libtmux.engines.connection.ServerConnection` is the pair every engine
needs before it can dispatch anything: which tmux *binary* to run, and the
connection flags (`-L`/`-S`/`-f`/`-2`/`-8`) naming one tmux server. It is the
connection flags (`-L`/`-S`/`-f`/`-2`) naming one tmux server. It is the
single place either is computed.

```{eval-rst}
Expand Down
14 changes: 4 additions & 10 deletions docs/experimental/engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ event loop.
## Engine boundary

A synchronous engine satisfies
{class}`~libtmux.experimental.engines.base.TmuxEngine`; an async engine
{class}`~libtmux.engines.base.TmuxEngine`; an async engine
satisfies {class}`~libtmux.experimental.engines.base.AsyncTmuxEngine`. Both
accept a {class}`~libtmux.experimental.engines.base.CommandRequest` and produce
a raw {class}`~libtmux.experimental.engines.base.CommandResult`.
accept a {class}`~libtmux.engines.base.CommandRequest` and produce
a raw {class}`~libtmux.engines.base.CommandResult`.
{func}`~libtmux.experimental.ops.run` and
{func}`~libtmux.experimental.ops.arun` own the next boundary: they render an
operation and convert the raw command outcome to its declared typed result.
Expand All @@ -68,16 +68,10 @@ engine-owned workflow.
## Shared API

```{eval-rst}
.. autoclass:: libtmux.experimental.engines.base.TmuxEngine
:members:

.. autoclass:: libtmux.experimental.engines.base.AsyncTmuxEngine
:members:

.. autoclass:: libtmux.experimental.engines.base.CommandRequest
:members:

.. autoclass:: libtmux.experimental.engines.base.CommandResult
.. autoclass:: libtmux.experimental.engines.base.SupportsAsyncTmuxVersion
:members:

.. autofunction:: libtmux.experimental.engines.registry.available_engines
Expand Down
43 changes: 40 additions & 3 deletions docs/experimental/engines/async-control-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,20 @@ and
Per-subscriber queues are bounded;
{attr}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine.dropped_notifications`
reports overflow. Connection failures and timeouts raise at the engine
boundary, while tmux command errors remain result data. Sequence anomalies are
logged.
boundary, while tmux command errors remain result data. The engine drains the
control process's stderr concurrently and includes its bounded recent tail in
connection and protocol failures. Cleanup joins that reader and reaps the
process before returning, including when the caller waiting on cleanup is
cancelled. A backwards command number or a solicited block with no pending
request raises a protocol error and restarts the connection rather than risking
result misattribution.

Tmux does not escape command-output lines inside these blocks. Output that
resembles a nonmatching guard remains data, but output byte-for-byte identical
to its own closing guard is indistinguishable from protocol framing. This is a
tmux control-protocol limitation; use
{class}`~libtmux.experimental.engines.asyncio.AsyncSubprocessEngine` when
arbitrary output must round-trip without that ambiguity.

The persistent process has normal tmux client semantics: `list-clients` shows
it, `session_attached` includes it, and client attach and detach hooks can
Expand All @@ -83,15 +95,40 @@ after a safe session exists; direct startup raises
available. The API has no subscriber-readiness signal, so code must not assume
a notification emitted before the first iteration will be delivered.

## Pane-output bytes

{class}`~libtmux.experimental.engines.async_control_mode.ControlNotification`
keeps the encoded, human-readable control line in `raw` and its exact bytes in
`raw_bytes` for wire diagnostics. For `%output` and `%extended-output`, it also
exposes the pane ID and decoded bytes in `pane_id` and `payload`. Decode text
only at the application boundary: tmux passes pane bytes through without
validating UTF-8.

```python
>>> from libtmux.experimental.engines import ControlNotification
>>> event = ControlNotification.parse(b"%output %7 hello\\012world\\134")
>>> event.pane_id, event.payload
('%7', b'hello\nworld\\')
>>> event.raw
'%output %7 hello\\012world\\134'
```

Tmux uses a backslash followed by exactly three octal digits for an escaped
byte. Shorter octal-looking text remains literal. Consumers normally read
`payload`; `raw` remains useful when diagnosing the protocol stream.

## API

```{eval-rst}
.. autoclass:: libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine
:members:
:special-members: __aenter__, __aexit__

.. autoclass:: libtmux.experimental.engines.async_control_mode.ControlNotification
:members:
```

## Related tutorial

See {doc}`../tutorials/async-control-plans` to compose forward-referenced
operations and fold them into control-mode dispatches.
operations and pipeline their ordered requests over control mode.
11 changes: 10 additions & 1 deletion docs/experimental/engines/control-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,16 @@ writes the batch before collecting correlated control-mode result blocks. The
engine drains unsolicited notifications so they are not mistaken for command
replies. Timeouts, connection death, and write failures raise
{exc}`~libtmux.experimental.engines.control_mode.ControlModeError`; tmux
`%error` blocks remain command-result data. Sequence anomalies are logged.
`%error` blocks remain command-result data. A backwards command number or a
solicited block with no pending request raises a protocol error and closes the
connection rather than risking result misattribution.

Tmux does not escape command-output lines inside these blocks. Output that
resembles a nonmatching guard remains data, but output byte-for-byte identical
to its own closing guard is indistinguishable from protocol framing. This is a
tmux control-protocol limitation; use
{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` when
arbitrary output must round-trip without that ambiguity.

The persistent process has normal tmux client semantics: `list-clients` shows
it, `session_attached` includes it, and client attach and detach hooks can
Expand Down
2 changes: 1 addition & 1 deletion docs/experimental/engines/imsg.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ method. Local queries and commands that must start a missing server use the
tmux binary instead. Unlike the subprocess and control-mode engines, it has no
`for_server()` helper. Put a private server's raw `-L` or `-S` global argument
in every
{class}`~libtmux.experimental.engines.base.CommandRequest`.
{class}`~libtmux.engines.base.CommandRequest`.

```python
>>> from libtmux.experimental.engines import CommandRequest, ImsgEngine
Expand Down
2 changes: 1 addition & 1 deletion docs/experimental/operations/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ effects directly from the operation registry.
## Execution contract

An engine emits a raw
{class}`~libtmux.experimental.engines.base.CommandResult`.
{class}`~libtmux.engines.base.CommandResult`.
{func}`~libtmux.experimental.ops.run` converts it to the operation's declared
{class}`~libtmux.experimental.ops.results.Result` subtype. Results preserve
failures as data; call
Expand Down
36 changes: 17 additions & 19 deletions docs/experimental/plans.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,24 @@ True
## Choose a planner

A {class}`~libtmux.experimental.ops.planner.Planner` turns a plan into
dispatches:
ordered execution steps:

- {class}`~libtmux.experimental.ops.planner.SequentialPlanner` sends one
dispatch per operation.
- {class}`~libtmux.experimental.ops.planner.FoldingPlanner` combines adjacent
chainable operations.
- {class}`~libtmux.experimental.ops.planner.MarkedPlanner` folds creation and
follow-up work by using tmux's `{marked}` register.
request per step.
- {class}`~libtmux.experimental.ops.planner.BatchingPlanner` combines adjacent
ready-to-render primitive operations while preserving one request and result
per operation.

All planners preserve per-operation results. They differ only in dispatch
shape. The callback in this live example records the dispatches: two chainable
option writes fold, while the output-bearing read stays separate.
All planners preserve per-operation results. They differ only in step shape;
the engine decides whether a batch is a control-mode pipeline or a series of
subprocess calls. The callback in this live example records one step containing
two option writes and an output-bearing read. Each result retains its own
stdout.

```python
>>> from libtmux.experimental.engines import SubprocessEngine
>>> from libtmux.experimental.ops import (
... FoldingPlanner,
... BatchingPlanner,
... LazyPlan,
... SessionId,
... SetOption,
Expand All @@ -79,11 +80,11 @@ option writes fold, while the output-bearing read stays separate.
>>> steps = []
>>> outcome = operation_plan.execute(
... SubprocessEngine.for_server(server),
... planner=FoldingPlanner(),
... planner=BatchingPlanner(),
... on_step=lambda report: steps.append(report.step.indices),
... ).raise_for_status()
>>> steps
[(0, 1), (2,)]
[(0, 1, 2)]
>>> type(outcome.results[2]).__name__
'ShowOptionsResult'
>>> (
Expand Down Expand Up @@ -120,9 +121,9 @@ True
Execution records the handle's concrete pane identifier in
{attr}`~libtmux.experimental.ops.plan.PlanResult.bindings`.

See {doc}`tutorials/async-control-plans` to compose chainable operations, inspect
their compiled tmux sequence, and execute the plan over one persistent async
control-mode client.
See {doc}`tutorials/async-control-plans` to compose ordered operations, inspect
their planner steps, and execute them over one persistent async control-mode
client.

## API reference

Expand Down Expand Up @@ -157,10 +158,7 @@ control-mode client.
.. autoclass:: libtmux.experimental.ops.planner.SequentialPlanner
:members:

.. autoclass:: libtmux.experimental.ops.planner.FoldingPlanner
:members:

.. autoclass:: libtmux.experimental.ops.planner.MarkedPlanner
.. autoclass:: libtmux.experimental.ops.planner.BatchingPlanner
:members:

.. autoclass:: libtmux.experimental.ops.planner.BoundedPlanner
Expand Down
2 changes: 1 addition & 1 deletion docs/experimental/results.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ All results retain the operation, rendered `argv`, `status`, `returncode`,
`stdout`, and `stderr`. `ok` is true only for `complete`; `failed` identifies a
tmux rejection or an incomplete composed operation.
{meth}`~libtmux.experimental.ops.results.Result.raise_for_status` raises for
`failed` and `unknown`, but returns both `complete` and `skipped` results. See
`failed` and `unknown`, but returns `complete` results. See
{doc}`tutorials/results-and-failures` for those paths in context.

## Choose the payload
Expand Down
Loading
Loading