Skip to content

Make tmux command execution pluggable, default path unchanged - #739

Open
tony wants to merge 4 commits into
masterfrom
engine-seam-minimal
Open

Make tmux command execution pluggable, default path unchanged#739
tony wants to merge 4 commits into
masterfrom
engine-seam-minimal

Conversation

@tony

@tony tony commented Aug 13, 2026

Copy link
Copy Markdown
Member

Bottom of a two-PR stack. #742 (typed operations and engines) is based
on this branch and lands after it.

Summary

  • Add a TmuxEngine seam: every tmux command libtmux runs — Server.cmd(), the listing queries behind Server.sessions, and Server.raise_if_dead() — goes through an engine object that takes a rendered argv and returns a structured result.
  • Add Server(engine=…) as the injection point. TmuxEngine is a typing.Protocol, so any object with run() and run_batch() qualifies; there is no base class to inherit and no dependency on libtmux's class hierarchy.
  • Keep the default path byte-for-byte: SubprocessEngine forks the tmux binary as before, cmd() still returns tmux_cmd, and arguments still reach tmux unchanged.
  • Consolidate binary lookup and the -L/-S/-f/-2/-8 flags into one ServerConnection. Three copies previously disagreed about which flags to emit, which is why config_file= and colors= were honored on some commands and not others.
  • Change tmux_cmd.process from a plain attribute to a read-only property. Reading it under the default engine is unchanged; it raises when the engine forked no process.
  • Guard the injection footgun: an engine that names no tmux server of its own adopts the server's connection, so injecting one into a socket-scoped Server cannot silently dispatch to the ambient tmux server.

This is the seam and nothing else. It exists so an alternative transport — control mode, a recording, an in-memory fake — can be substituted without copying the library, which is what the downstream work currently has to do.

Changes by area

New: src/libtmux/engines/

  • base.py: CommandRequest (a tmux argv, without connection flags), CommandResult (the structured outcome), the TmuxEngine protocol, and three optional capability protocols — SupportsCommandLine (render the argv without running it, which is how the full command line reaches the debug log), SupportsConnection (marks an engine that dispatches over a named server and can be rebound), and SupportsTmuxVersion (report the tmux version, for callers rendering version-gated argv). Also CommandSeparator / is_command_separator, which mark an intentional command boundary so a ; passed as data can never become one.
  • connection.py: ServerConnection, the sole owner of the tmux binary path and connection flags. Derived from the server's public attributes on each use, so reassigning socket_name takes effect on the next command, and it memoizes its shutil.which lookup rather than re-walking $PATH.
  • subprocess.py: SubprocessEngine, the default.

Routed through the seam

  • src/libtmux/server.py: engine and connection properties, the engine= argument with validation at construction, and raise_if_dead() routed through dispatch.
  • src/libtmux/neo.py: fetch_objs() dispatches through the server's engine instead of building its own flags. Without this the object API never touches the engine, and an alternative engine cannot back sessions / windows / panes at all.
  • src/libtmux/common.py: tmux_cmd takes an engine= and is built from the engine's CommandResult. It reads process defensively, so an engine may return any structurally compatible result rather than only libtmux's own.

Design decisions

A protocol, not a base class. Structural typing means a third-party engine needs no import-time dependency on libtmux — it implements run and run_batch and is an engine. TmuxEngine's own method bodies are ..., so it is a shape to satisfy, not an implementation to inherit; a stateless engine writes run_batch as a loop over run.

run_batch stays on the protocol even though core never calls it. It is the override point where a persistent-connection engine pipelines instead of round-tripping per command. "Nothing calls it" is the reasoning that would delete the extension point the seam exists to provide.

tmux rejecting a command is data; never reaching tmux is an exception. A nonzero result sets returncode and stderr on the result object. Only a broken engine — missing binary, lost connection — raises.

cmd() keeps returning tmux_cmd. tmux_cmd is load-bearing in public annotations, so the compatibility path adapts the engine's CommandResult back into one rather than introducing a new return type. Returning CommandResult directly is a breaking change and is deliberately not part of this PR.

Arguments are not escaped. tmux treats a trailing ; on an argument as a command boundary, and libtmux has always relied on that parse. Fixing it is a real behavior change with a public-API consequence, so it belongs in its own PR rather than riding along with a seam.

Out of scope, by design

Each of these builds on the seam and can land independently: batching (cmd_batch), async engines, record/replay, name-based engine resolution and entry points, block-scoped engine swapping, control-mode codecs, argument escaping, CommandResult as the return of cmd(), and ok / raise_for_status result helpers.

Behavior change

One, filed under Breaking changes in CHANGES. Server.raise_if_dead() previously let tmux write its message straight to the terminal; routed through an engine, that text is captured onto the raised subprocess.CalledProcessError. The exception type is unchanged.

It is routed rather than left alone because otherwise the three flag builders still disagree — the defect this seam exists to fix — and a non-forking engine cannot have its liveness probe shelling out.

Verification

The default path is unchanged, which means the object-API tests must pass untouched:

$ git diff --stat origin/master...HEAD -- tests/test_server.py tests/test_session.py tests/test_window.py tests/test_pane.py tests/test_neo.py

No escaping machinery ships:

$ rg -n 'CommandSeparator|encode_direct_argv|split_direct_argv' src/ tests/ docs/

cmd() still returns the compatibility type:

$ rg -n -A 6 'def cmd\(' src/libtmux/server.py

Test plan

  • uv run mypy . — clean
  • uv run ruff check . and uv run ruff format . --check — clean
  • uv run pytest — full suite passes with zero edits to the object-API test modules
  • just build-docs — builds, including the new topic and API pages
  • test_flag_builders_agreecmd(), raise_if_dead() and fetch_objs() emit identical connection flags for a server with socket_name, config_file and colors set
  • test_unknown_color_raises_on_every_path — an unknown colors value raises on all three paths, matching Server.cmd()'s existing contract
  • test_server_drives_injected_engine_without_tmux — an injected engine backs cmd() with no tmux server running
  • test_process_is_popen_under_default_enginetmux_cmd.process reads as it did before the seam existed
  • test_server_drives_engine_returning_a_foreign_result — an engine returning a result type libtmux does not own drives cmd(); verified to fail without the defensive read
  • test_injected_engine_survives_mutation and test_default_engine_rebuilt_after_mutation — an injected engine is user-owned; the default one tracks socket_name changes

Related

The full engine feature set is #738. This branch is the strict subset of it that is only the seam, for review or landing ahead of the rest.

@tony
tony force-pushed the engine-seam-minimal branch from ed7206c to 18e3d72 Compare August 15, 2026 10:36
@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 62.60504% with 89 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.54%. Comparing base (988b02a) to head (0bcf5b9).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
src/libtmux/engines/connection.py 63.07% 24 Missing ⚠️
src/libtmux/engines/subprocess.py 50.00% 21 Missing and 1 partial ⚠️
src/libtmux/engines/base.py 44.11% 19 Missing ⚠️
src/libtmux/server.py 72.22% 14 Missing and 1 partial ⚠️
src/libtmux/common.py 80.64% 6 Missing ⚠️
src/libtmux/exc.py 66.66% 2 Missing ⚠️
src/libtmux/window.py 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #739      +/-   ##
==========================================
+ Coverage   52.37%   52.54%   +0.17%     
==========================================
  Files          26       29       +3     
  Lines        3729     3903     +174     
  Branches      747      759      +12     
==========================================
+ Hits         1953     2051      +98     
- Misses       1472     1550      +78     
+ Partials      304      302       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tony
tony force-pushed the engine-seam-minimal branch from 18e3d72 to b9deeb2 Compare August 15, 2026 12:23
why: Every tmux command forks the binary inline, so an alternative
transport -- control mode, a recording, an in-memory fake -- cannot be
substituted without copying the library, which is what the downstream
work had to do. Connection flags were built in three places that
disagreed, so config_file= and colors= reached tmux on some paths and
not others.

what:
- Route dispatch through a TmuxEngine protocol, defaulting to a
  subprocess engine that forks exactly as before
- Accept engine= on Server, and let an engine that names no server of
  its own adopt the server's connection rather than the ambient one
- Derive one ServerConnection for cmd(), raise_if_dead() and
  fetch_objs()
- Read the result's process field defensively, so an engine may return
  any structurally compatible result rather than only ours
- Mark intentional command boundaries with CommandSeparator, so a ";"
  passed as data can never become one
- Report a connection's tmux version behind SupportsTmuxVersion, for
  callers that render version-gated argv
- Keep cmd() returning tmux_cmd, and arguments reaching tmux
  unchanged, so the default path behaves as it did
tony added 3 commits August 16, 2026 07:00
why: A custom tmux_bin names a program, not a server. An engine built
with one and no -L/-S was treated as already knowing its server, so it
was left unbound and every command reached whichever tmux server a
flagless dispatch finds -- the silent ambient dispatch adoption exists
to prevent.

what:
- Add ServerConnection.names_server, asking whether a connection
  carries connection flags of its own; the engine side of adoption
  reads it instead of is_unconfigured, which keeps its server-side
  meaning of "carries nothing at all"
- Bind the server's flags onto such an engine while preserving the
  binary it was built with
- Document the binary-is-not-a-server rule on Server.engine and in
  the CHANGES deliverable prose
- Cover both adoption directions plus the two cases that already
  held, so a single-predicate regression cannot pass
why: The engine captures tmux's stderr instead of letting it reach the
terminal, and the raise then discarded it, so a caller was left with an
exit code and no way to recover what tmux said -- strictly less than
the message the terminal used to show.

what:
- Pass the captured stdout and stderr to CalledProcessError, matching
  what CompletedProcess.check_returncode raises
- Say so in the docstring and prove it in the doctest
- Assert the socket name reaches the exception, which holds across
  both wordings tmux uses for an unreachable server
why: TmuxEngine and SupportsCommandLine are runtime_checkable Protocols,
so isinstance() checks attribute names only -- never signatures, never
async-ness. An engine with `async def run` satisfied them, was accepted
by Server(engine=...), and failed on the first command with
`AttributeError: 'coroutine' object has no attribute 'cmd'`, naming
neither the engine nor the mismatch. An `async def command_line`
failed the same way, one line earlier, whenever DEBUG logging was on.

what:
- Guard every engine capability in one place, _guard_sync(), reached
  through the typed _dispatch_run() and _dispatch_command_line()
  wrappers, so a mistyped call site is a mypy error rather than a
  runtime AttributeError
- Collapse raise_if_dead onto self.cmd(), deleting the second dispatch
  site rather than guarding it twice
- Reject a declared-async member before calling it, so the common shape
  never creates a coroutine; test the returned value too, since a plain
  def can still hand one back
- Close a coroutine that did get created -- safe while unstarted, and
  suppressed against BaseException so a hostile awaitable cannot
  replace the diagnostic. Never cancel a Task or Future: one bound to
  another thread's loop would not receive it, and one shared with
  another awaiter would lose its result
- Let AsyncEngineMismatch escape the list-accessor leniency; a
  misconfigured engine is not a tmux failure and must not read as
  "no sessions"
- Add exc.AsyncEngineMismatch, naming the engine and the method, and
  document it on cmd() for Server, Session, Window and Pane
- Show the failure as a runnable example in docs/topics/engines.md

An eagerly-started Task (3.12+) has already run its body before run()
returns; the guard reports it but cannot undo it. Nothing dispatches
run_batch in-tree, so it gets no guard.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant