TmuxController fuses tmux semantics to host transport. There is no seam between "what tmux command to run" and "how to run it on this host", so the module's real behaviour can only be exercised by shelling out for real.
Evidence
As of ab5ff38:
- Three
subprocess.run call sites inside tmuxctl.py (lines 336, 460, 519). The socket work added the third.
- The ssh path is never tested. The tests that drive real behaviour (
test_resolve_sockets_from_file_on_local_host, test_list_host_surfaces_socket_resolution_errors) shell out via tmp_path, so they only ever cover local=True. Every ssh flag, sshpass/SSHPASS handling, and timeout path is verified only by asserting on the argv _build_argv returns — never by running it.
- Tests reach past the interface to compensate.
tests/test_tmuxctl.py:161 monkeypatches _run_mutation; :373 monkeypatches _parse_list_output. Both are private. They are standing in for a seam that does not exist.
- The covered helpers (
_build_argv, _parse_list_output, _socket_flag, _order_key) sit on either side of the code that actually breaks — argv construction and output parsing are tested, the execution between them is not.
tmuxctl.py is now 633 lines and growing (was 428 before the socket and session-order work).
Proposal
Extract a HostExec module: one interface — run a script on a host, return the result — with the transport details behind it.
LocalExec wraps bash -c
SshExec owns ssh flags, sshpass/SSHPASS env, StrictHostKeyChecking, ConnectTimeout, port, -tt
FakeExec in tests
TmuxController keeps only tmux semantics: building script strings, resolving sockets, parsing output. list_host, kill_session, create_session, and socket resolution become testable through the public interface, on both transports.
The seam is real rather than hypothetical: local and ssh are two genuine adapters today, currently expressed as an if host.local branch in _build_argv.
Open design questions
Not yet decided — these should be settled before implementation:
- Where exactly does the seam sit?
HostExec.run(host, script, *, timeout, interactive) with two adapters selected per host, or one adapter branching internally on host.local, or a lower Runner.run(argv, env, timeout) that leaves _build_argv on the caller's side. Only the first makes local-vs-ssh two real adapters.
- Raise or return?
list_host currently folds TimeoutExpired/OSError into a failure dict while _run_mutation raises TmuxctlError, and config faults from _build_argv escape list_host uncaught. Candidate answer: return a value for anything that happened on the wire, raise TmuxctlError for pre-launch config faults — preserving today's observable behaviour exactly.
- How does the fake get injected, and is it public?
TmuxController is not exported from __init__.py, not in the README, and not used by examples/ — its only callers are board.py and the tests. A private constructor param costs no public interface; surfacing it on Muxboard for third-party transports (docker, k8s) is speculative until asked for.
Also in scope to decide: whether attach_argv crosses this seam or keeps handing argv to ttyproxy.bridge for PTY spawning (board.py:337), and who owns the timeout multipliers (x3 for list, x2 for mutations).
Why it matters
- Locality: ssh invocation lives in one implementation instead of leaking into three call sites.
- Leverage: one interface serves list, kill, create, socket resolution, and attach.
- The interface becomes the test surface, so tests stop monkeypatching privates.
TmuxControllerfuses tmux semantics to host transport. There is no seam between "what tmux command to run" and "how to run it on this host", so the module's real behaviour can only be exercised by shelling out for real.Evidence
As of ab5ff38:
subprocess.runcall sites insidetmuxctl.py(lines 336, 460, 519). The socket work added the third.test_resolve_sockets_from_file_on_local_host,test_list_host_surfaces_socket_resolution_errors) shell out viatmp_path, so they only ever coverlocal=True. Every ssh flag,sshpass/SSHPASShandling, and timeout path is verified only by asserting on the argv_build_argvreturns — never by running it.tests/test_tmuxctl.py:161monkeypatches_run_mutation;:373monkeypatches_parse_list_output. Both are private. They are standing in for a seam that does not exist._build_argv,_parse_list_output,_socket_flag,_order_key) sit on either side of the code that actually breaks — argv construction and output parsing are tested, the execution between them is not.tmuxctl.pyis now 633 lines and growing (was 428 before the socket and session-order work).Proposal
Extract a
HostExecmodule: one interface — run a script on a host, return the result — with the transport details behind it.LocalExecwrapsbash -cSshExecowns ssh flags,sshpass/SSHPASSenv,StrictHostKeyChecking,ConnectTimeout, port,-ttFakeExecin testsTmuxControllerkeeps only tmux semantics: building script strings, resolving sockets, parsing output.list_host,kill_session,create_session, and socket resolution become testable through the public interface, on both transports.The seam is real rather than hypothetical: local and ssh are two genuine adapters today, currently expressed as an
if host.localbranch in_build_argv.Open design questions
Not yet decided — these should be settled before implementation:
HostExec.run(host, script, *, timeout, interactive)with two adapters selected per host, or one adapter branching internally onhost.local, or a lowerRunner.run(argv, env, timeout)that leaves_build_argvon the caller's side. Only the first makes local-vs-ssh two real adapters.list_hostcurrently foldsTimeoutExpired/OSErrorinto a failure dict while_run_mutationraisesTmuxctlError, and config faults from_build_argvescapelist_hostuncaught. Candidate answer: return a value for anything that happened on the wire, raiseTmuxctlErrorfor pre-launch config faults — preserving today's observable behaviour exactly.TmuxControlleris not exported from__init__.py, not in the README, and not used byexamples/— its only callers areboard.pyand the tests. A private constructor param costs no public interface; surfacing it onMuxboardfor third-party transports (docker, k8s) is speculative until asked for.Also in scope to decide: whether
attach_argvcrosses this seam or keeps handing argv tottyproxy.bridgefor PTY spawning (board.py:337), and who owns the timeout multipliers (x3for list,x2for mutations).Why it matters