Make tmux command execution pluggable, default path unchanged - #739
Open
tony wants to merge 4 commits into
Open
Conversation
tony
force-pushed
the
engine-seam-minimal
branch
from
August 15, 2026 10:36
ed7206c to
18e3d72
Compare
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
tony
force-pushed
the
engine-seam-minimal
branch
from
August 15, 2026 12:23
18e3d72 to
b9deeb2
Compare
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
force-pushed
the
engine-seam-minimal
branch
from
August 15, 2026 12:32
b9deeb2 to
947eaa4
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
TmuxEngineseam: every tmux command libtmux runs —Server.cmd(), the listing queries behindServer.sessions, andServer.raise_if_dead()— goes through an engine object that takes a rendered argv and returns a structured result.Server(engine=…)as the injection point.TmuxEngineis atyping.Protocol, so any object withrun()andrun_batch()qualifies; there is no base class to inherit and no dependency on libtmux's class hierarchy.SubprocessEngineforks the tmux binary as before,cmd()still returnstmux_cmd, and arguments still reach tmux unchanged.-L/-S/-f/-2/-8flags into oneServerConnection. Three copies previously disagreed about which flags to emit, which is whyconfig_file=andcolors=were honored on some commands and not others.tmux_cmd.processfrom a plain attribute to a read-only property. Reading it under the default engine is unchanged; it raises when the engine forked no process.Servercannot 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), theTmuxEngineprotocol, 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), andSupportsTmuxVersion(report the tmux version, for callers rendering version-gated argv). AlsoCommandSeparator/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 reassigningsocket_nametakes effect on the next command, and it memoizes itsshutil.whichlookup rather than re-walking$PATH.subprocess.py:SubprocessEngine, the default.Routed through the seam
src/libtmux/server.py:engineandconnectionproperties, theengine=argument with validation at construction, andraise_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 backsessions/windows/panesat all.src/libtmux/common.py:tmux_cmdtakes anengine=and is built from the engine'sCommandResult. It readsprocessdefensively, 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
runandrun_batchand is an engine.TmuxEngine's own method bodies are..., so it is a shape to satisfy, not an implementation to inherit; a stateless engine writesrun_batchas a loop overrun.run_batchstays 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
returncodeandstderron the result object. Only a broken engine — missing binary, lost connection — raises.cmd()keeps returningtmux_cmd.tmux_cmdis load-bearing in public annotations, so the compatibility path adapts the engine'sCommandResultback into one rather than introducing a new return type. ReturningCommandResultdirectly 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,CommandResultas the return ofcmd(), andok/raise_for_statusresult 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 raisedsubprocess.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.pyNo 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.pyTest plan
uv run mypy .— cleanuv run ruff check .anduv run ruff format . --check— cleanuv run pytest— full suite passes with zero edits to the object-API test modulesjust build-docs— builds, including the new topic and API pagestest_flag_builders_agree—cmd(),raise_if_dead()andfetch_objs()emit identical connection flags for a server withsocket_name,config_fileandcolorssettest_unknown_color_raises_on_every_path— an unknowncolorsvalue raises on all three paths, matchingServer.cmd()'s existing contracttest_server_drives_injected_engine_without_tmux— an injected engine backscmd()with no tmux server runningtest_process_is_popen_under_default_engine—tmux_cmd.processreads as it did before the seam existedtest_server_drives_engine_returning_a_foreign_result— an engine returning a result type libtmux does not own drivescmd(); verified to fail without the defensive readtest_injected_engine_survives_mutationandtest_default_engine_rebuilt_after_mutation— an injected engine is user-owned; the default one trackssocket_namechangesRelated
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.