Skip to content

Put a seam under host execution in tmuxctl #7

Description

@JacobStephens2

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:

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions