Skip to content

API(feat): Add owned scopes and command results - #758

Open
tony wants to merge 52 commits into
masterfrom
api-improvements
Open

tony wants to merge 52 commits into
masterfrom
api-improvements

Conversation

@tony

@tony tony commented Sep 13, 2026

Copy link
Copy Markdown
Member

Summary

  • Add run_command() and CommandResult for explicit process execution and captured results, while retaining the tmux_cmd compatibility facade.
  • Bound command and listing calls with optional server-wide and per-call timeouts. Expired calls reap their child process and raise TmuxTimeout, including through otherwise lenient listings.
  • Own a private daemon endpoint with Server.owned() or a newly created session with Server.owned_session(). Session cleanup verifies session and daemon identity; both scopes expose cleanup failures.
  • Expose a public QueryList import with precise required/defaulted lookups, plus local numeric and boolean properties that retain the raw tmux fields.
  • Preserve captured paths containing newlines so one pane's directory does not break listings for the server.
  • Decode the additional pane, window, client and command hooks reported by newer tmux versions through the typed hook table.
  • Correct automation examples to distinguish output from echoed commands, accept padded completion markers while preserving captured payload, verify running and completed states, and stop queues after a timeout. Keep executable context-manager guidance and explain terminal-client popup requirements and floating-pane borders.

Command results

The compatibility constructor remains available:

from libtmux.common import tmux_cmd

result = tmux_cmd("-V")

Callers can select the explicit execution function:

from libtmux.common import run_command

result = run_command("-V")

Both paths preserve output decoding and completed nonzero exit statuses. Classic listing and refresh behavior remains compatible; the new ownership scopes create their resources rather than adopting existing ones. Context managers on existing handles retain their documented destructive behavior.

Test plan

  • Ruff formatting, lint and mypy validate the public types and test fixtures.
  • Local full suites cover stable tmux, the supported tmux 3.2a floor and current upstream master with reruns disabled, including command timeouts, multiline fields, typed properties, ownership, control replies and terminal-backed popup tests where supported.
  • Early coverage includes pytest-plugin imports and worker subprocesses; focused ownership coverage exercises cleanup both with and without a running daemon.
  • Strict Sphinx builds and rendered-content inspection validate API references, context managers, automation examples and the final changelog.
  • A public Python 3.10 consumer exercises the modified package against tmux 3.2a; supported-version tests retain explicit unsupported-feature skips.
  • Deliberate regressions prove cleanup errors remain visible, owned resources survive failed teardown, control replies drain buffered output, and automation does not accept echoed or stale markers.
  • Real terminal and daemon fault probes verify fixture cleanup after startup and wait failures, including interrupted registration, stopped-client escalation and closure of owned process streams.

Related work

The branch includes the command-timeout support proposed in PR #757.

Remediation round (2026-09-15)

  • Fixed Server.server_access() emitting flags after the positional
    user (-a alice -r), which tmux's own arg parser read as two positional
    arguments and rejected as "too many arguments" -- -r/-w silently
    never applied whenever combined with allow/deny. Flags now precede
    the user.
  • Gated the master-tmux CI matrix lane on its own Test with pytest
    step, dropping continue-on-error. Verified green against tmux's real
    git master across recent pushes before flipping.
  • Added a runnable examples/ directory -- python examples/quickstart.py
    and friends, each using Server.owned(), executed by the test suite as
    real subprocesses (tests/test_examples.py), documented at
    docs/topics/examples.md.
  • Decided ControlMode stays internal: it decodes none of tmux's
    control-mode protocol and is a test-only client, not a streaming API.
    docs/topics/public-vs-internal.md states this and names the
    alternatives (polling, Pane.pipe(), hooks); public docstrings that
    pointed at the internal class by name were reworded.
  • Added a benchmarks/ suite (just bench, pytest-benchmark) covering
    command dispatch, listing, snapshot capture, and format decoding --
    a separate tier from the gates, not part of pytest's default run.
  • Added coverage for tmux 3.8's format changes: a dead pane's
    #{pane_pid} (now empty, not "0") never reaches a numeric coercion,
    and a saved #{window_layout} round-trips byte-exact through
    select_layout().

tony added 9 commits August 29, 2026 05:14
why: tmux writes one record per line, but any format value may itself
contain a newline, which splits that record across output lines. A
parser that iterates lines cannot recover the boundaries. Regrouping on
the field separator can: the `-F` template from `get_output_format`
terminates every field with one, so a record holds exactly
`len(fields)` separators and a newline is never among them.

what:
- Add `_split_records`, which rejoins stdout into one blob, splits it
  on the field separator, and regroups the values into records of
  `field_count` fields
- Drop the empty tail the split leaves, since every record ends with a
  separator
- Strip the newline that terminated the previous record, which the
  rejoin leaves glued to the next record's first value
- Raise `LibTmuxException` naming the cause when the values do not
  divide into whole records, which means a value carried the separator
  itself
- Cover newlines in the first, middle, and last field, consecutive
  newlines, a poisoned record between clean ones, a forged separator,
  and an empty listing

Nothing calls it yet; the next commit points `fetch_objs` at it.
why: A pane whose `pane_current_path` contained a newline made
`Server.panes` and `Server.windows` raise `ValueError: zip() argument
2 is shorter than argument 1` for the entire server, healthy panes
included. `fetch_objs` iterated stdout one line per object, so a value
containing a newline split its record across two lines and each
fragment reached `parse_output` with too few values.

Every pane row carries `pane_current_path` and every pane-targeting
lookup enumerates panes, so one directory took out resolution for all
of them. The blast radius also moved with the active pane, because
session and window rows resolve `pane_*` against it — the same server
appeared to work or fail as the user switched panes.

Reported against libtmux-mcp, where an agent hit it by cd-ing a pane
into such a directory and then could not repair it through the MCP,
because every tool that could have moved the pane needed the same
enumeration.

what:
- Build the `parse_output` inputs with `_split_records` instead of
  iterating `proc.stdout` line by line, so a value may hold any number
  of newlines, in any position
- Surface a `LibTmuxException` naming the cause, rather than a `zip()`
  message, when a value carries the separator itself
why: `tmux_cmd` waited on `Popen.communicate()` with no deadline, so a
tmux server that accepts a connection and never replies held its caller
forever. Cancelling the coroutine that awaits such a call does not
interrupt it, so hung calls only accumulate; downstream, forty of them
exhausted anyio's default thread limiter and the host process stopped
serving every socket, healthy ones included.

`TmuxTimeout` is deliberately NOT a `LibTmuxException`. The listing
accessors absorb one of those as "nothing to list", which is right for
a daemon that has not started and wrong for a server that stopped
answering: a caller told there are no sessions goes on to create one on
a server that already has them. A sibling type gets that for free at
every such site.

what:
- Add `exc.TmuxTimeout`, carrying the argv and the bound it passed
- Add `tmux_cmd(..., timeout=)`; on expiry kill the child and reap it
  before raising, so repeated timeouts do not leave tmux processes
  nothing is waiting on
- Add a `hanging_tmux` fixture: a stand-in that answers `-V` and hangs
  on everything else, which is the shape of a wedged server
- Cover the raise, and that the process is gone afterwards. Shown
  failing on the kill: without it the pid is still alive
why: `Server.cmd` is not the only funnel. `neo.fetch_objs` builds a
`tmux_cmd` directly and is the engine behind `Server.sessions`,
`Session.windows` and `Window.panes`, so a consumer cannot bound its
calls with a `Server` subclass -- the busiest path is not reachable
that way.

what:
- Add `Server(timeout=)`, used by `Server.cmd` unless a call overrides it
- Pass the server's timeout through `fetch_objs`
- Assert every listing accessor raises rather than answering empty on a
  wedged server: `sessions`, `windows`, `panes`, `clients`. That is what
  the sibling exception type buys, and the parametrization is what shows
  it holds at all four
why: Execution and captured results need separate APIs.

what: Add run_command and CommandResult while preserving tmux_cmd behavior.
why: Callers need a stable import and precise required/defaulted results.

what: Export the existing QueryList and add compatible get overloads.
why: Numeric and boolean state should be usable without manual parsing.

what: Add local typed properties while preserving raw fields and aliases.
why: Cleanup should target only resources created by the scope.

what: Add private server and guarded session scopes, and document legacy
context manager destruction.
Check both exit status and stderr before removing an owned server's socket directory. Keep completed failures visible and the endpoint available for retry without changing legacy Server.kill behavior.
@codecov

codecov Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.54839% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.79%. Comparing base (036c521) to head (af4bda8).
⚠️ Report is 11 commits behind head on master.

Files with missing lines Patch % Lines
src/libtmux/server.py 89.04% 6 Missing and 2 partials ⚠️
src/libtmux/_internal/control_mode.py 75.00% 3 Missing and 1 partial ⚠️
src/libtmux/common.py 96.36% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master     #758       +/-   ##
===========================================
+ Coverage   52.37%   81.79%   +29.42%     
===========================================
  Files          26       28        +2     
  Lines        3729     3873      +144     
  Branches      747      749        +2     
===========================================
+ Hits         1953     3168     +1215     
+ Misses       1472      401     -1071     
  Partials      304      304               

☔ 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.

why: The owned-scope guide displaced useful examples even though
ordinary context manager behavior remains supported.

what:
- Restore server, session, window, pane and nested examples
- Assert cleanup order and exception cleanup
- Keep explicit ownership guidance alongside the walkthrough
tony added 13 commits September 13, 2026 11:02
why: Scheduler delays can exceed the elapsed-time assertions even when
retry behavior is correct.

what:
- Advance a clock local to the retry module without sleeping
- Verify attempts, intervals and timeout failure channels
- Preserve success and failure coverage in parameterized tests
why: Pytest imports the libtmux plugin before pytest-cov starts, leaving
executed declarations absent from the coverage report.

what:
- Start coverage before pytest and combine worker process data
- Require coverage with built-in subprocess instrumentation
- Document the local command and quote workflow filesystem values
why: Command startup and completed cleanup refusals must preserve the
original failure and enough context for callers to recover.

what:
- Exercise permission failures through both command entry points
- Exercise session cleanup refusals with and without stderr
- Assert retained sessions and chained body errors remain accessible
why: A permanent master cache kept testing an upstream bug already
fixed in current tmux, and matrix jobs competed to save one uv cache.

what:
- Resolve and validate each tmux ref before caching and checkout
- Include platform and source revision in the tmux cache key
- Let one matrix job save the shared dependency cache
why: New tmux hooks made complete show-hooks output fail typed decoding.

what:
- Append 22 typed sparse hook fields with documented event meanings
- Exercise new hooks against tmux 3.8 through set/show/unset cycles
- Verify removed after-queue rejects setting on 3.8
why: tmux 3.8 measures floating geometry including borders, while pane
formats report the content area.

what:
- Describe size and position semantics in both creation methods
- Show exact content placement with borders disabled
- Verify default-border size and coordinates across tmux versions
why: Qualified overload declarations are not executable, and record
splitting always produces at least one field.

what:
- Exclude both supported overload decorator spellings
- Remove the impossible empty split branch
- Cover malformed records with and without a trailing separator
why: Selecting the pipe misses output already held by its text reader,
so the UTF-8 regression can time out after valid data arrives.

what:
- Detach after the marker and collect the remaining process output
- Preserve the locale-based decoding regression and bounded wait
why: The echoed command contains the completion marker before its
output has reached the terminal.

what:
- Print the marker on its own line
- Wait for an exact joined capture line before checking output
why: Completed cleanup failures must retain the owned endpoint, and
fixture setup errors must not bypass native teardown.

what:
- Refuse cleanup through a real tmux wrapper
- Cover exit status and stderr independently
- Capture the endpoint before startup and preserve cleanup errors
why: Control clients do not execute popup commands on tmux master.

what:
- Exercise popup flags and completion through a real terminal client
- Bound process cleanup and close PTYs on setup or wait failure
- State the control-client limitation in its module documentation
why: A scope without a session still owns a temporary directory.

what:
- Verify directory removal without a tmux executable
- Keep failed assertions from leaking the temporary directory
why: Echoed commands and stale markers can report completion too early.

what:
- Match complete output lines and use fresh markers for repeated work
- Verify running and completed states without shell startup timing
- Stop retries and task queues after an unfinished command times out
- Describe popup requests against terminal and control clients
why: Joined captures on older tmux pad completed marker lines, so
examples and capture tests timed out after their commands finished.

what:
- Compare complete marker lines after removing right-padding spaces
- Preserve captured payloads and reject echoed or stale markers
why: Reaping control clients left their output streams open, and an
interrupted registration bypassed cleanup.

what:
- Share process and stream cleanup across exit and failed startup
- Cover ordinary, stopped and interrupted clients with real processes
why: The no-text test accepted a command refusal on the oldest tmux
because both successful display and failure returned None.

what:
- Use implicit client selection and fail on unexpected warnings
- Explain the native client-option parsing limitation accurately
why: tmux can return before the shell prompt or command completion,
so immediate output assertions race the pane process.

what:
- Wait for the initial prompt and completed command output
- Keep the original payload and capture-bound assertions
why: tmux can return before a split shell and its command output
are ready, so a fixed sleep leaves environment assertions racy.

what:
- Wait for the initial split-shell prompt
- Retry each environment output with a bounded deadline
why: Explain the complete API and automation changes to upgrading users.

what:
- Cover command results, timeouts, owned scopes and captured fields
- Document typed queries, newline-safe listings and hook decoding
- Describe executable automation and terminal geometry examples
tony added 23 commits September 13, 2026 13:15
why: new_session read proc.stdout[0] straight off the new-session
reply, so a value containing a newline (pane_current_path, echoed
because a session row also reports its active pane's fields --
reachable through start_directory) split the record across output
lines. parse_output's strict zip then rejected the truncated fragment
with ValueError before a Session was ever built. fetch_objs got the
same fix already; this was the other parse call site it missed.

what:
- Regroup proc.stdout with _split_records, matching fetch_objs, before
  handing the record to parse_output
- Add a start_directory-with-newline regression test
why: The stub script built pid_file's path straight into an unquoted
shell redirection. tmp_path doesn't carry a space by default, but a
custom --basetemp or a differently configured runner can hand pytest
one, and the script would break on it instead of the intended hang.

what:
- shlex.quote the interpolated path before writing it into the script
why: On TimeoutExpired, run_command killed the process then called a
bare communicate() to reap it. kill() ends the timed-out process
immediately, but a descendant that inherited its stdout/stderr pipes
(and outlives it) keeps them open, so reading for EOF blocked on that
descendant's lifetime instead of completing anywhere near the caller's
own deadline -- observed blocking well past it in a reproduction with
an orphaned pipe holder.

what:
- Bound the post-kill communicate() with _KILL_REAP_TIMEOUT; on a
  second TimeoutExpired, close libtmux's own pipe ends and wait() the
  already-killed process instead of continuing to read
- Add hanging_tmux_with_orphan, a stub that backgrounds and disowns a
  child before exec, to reproduce a surviving pipe holder
- Regression test asserting the drain stays bounded and the timed-out
  process is gone
why: __init__ forwarded a pre-formatted message string to
Exception.__init__, so self.args held one string while the
constructor itself requires (cmd, timeout, *args). pickle and copy
reconstruct exceptions via type(exc)(*exc.args), which raised
TypeError: missing 1 required positional argument: 'timeout' --
surfacing under e.g. ProcessPoolExecutor, which pickles exceptions to
send them back to the parent process.

what:
- Forward the constructor's own arguments to super().__init__ instead
  of a formatted string
- Move the formatted message to __str__, so str(exc) is unchanged
- Add a pickle/copy/deepcopy round-trip test
…erver

why: _split_records raised the generic LibTmuxException for a value
that carried the field separator. Server.sessions and Server.clients
catch that broad type and return QueryList([]), which is the correct,
tested contract for an unreachable server (no daemon, missing socket,
permission error, subprocess crash) but wrong here: the invocation
succeeded and tmux may hold rows libtmux simply could not parse back.
Before _split_records existed, this same condition raised a bare
ValueError, which that except clause never caught, so it propagated --
the empty-by-default contract silently widened to cover a case it was
never meant to.

Server.windows and Server.panes were unaffected: _fetch_or_empty only
absorbs the daemon-not-up string, so they already raised on this.

what:
- Add exc.TmuxRecordParseError, a LibTmuxException subtype, and raise
  it from _split_records instead of the bare base class
- Server.sessions/Server.clients re-raise TmuxRecordParseError before
  falling through to the existing empty-on-LibTmuxException handling,
  so a generic tmux failure still yields QueryList([]) but a parse
  failure propagates, matching windows/panes
- Update the docstrings, both AGENTS.md files, and fetch_objs' Raises
  section for the narrowed contract
- Add propagation tests mirroring the existing empty-on-error tests
why: raise_if_dead called subprocess.check_call directly, bypassing
Server.cmd entirely, so it ignored Server.timeout and could block
forever against a wedged server -- the one place still able to hang
after this PR bounded every other command path. is_alive's bare
`except Exception: return False` swallowed the new TmuxTimeout the
same way it swallows a real "no server here", so a wedged server
(alive, just not answering) was reported dead. Server.__exit__ does
`if self.is_alive(): self.kill()`, so that false "dead" made it skip
the kill and leak the daemon.

what:
- raise_if_dead now runs "list-sessions" through Server.cmd instead of
  a bare subprocess.check_call, so it honors Server.timeout and raises
  subprocess.CalledProcessError on a non-zero exit, matching its
  documented contract (also stops leaking list-sessions output to the
  parent's stdout, which check_call did)
- is_alive re-raises TmuxTimeout before its catch-all: a wedged server
  is not a dead one, so it must not collapse to False
- __exit__ treats a timeout from is_alive as "unknown, assume alive"
  and attempts the kill regardless, rather than skipping it -- if the
  server really is wedged, kill() will itself time out and raise, a
  loud leak instead of a silent one
- Update the per-file BLE001 ruff ignore's comment, which predates
  TmuxTimeout, to name the one exception it no longer covers
- Add timeout-propagation tests for is_alive/raise_if_dead and a test
  that __exit__ still attempts the kill when is_alive times out
…he session

why: owned_session created the session, then ran bare asserts and
int() conversions to build its identity-checked cleanup predicate
before ever entering the try/finally that runs that cleanup. A failure
in that gap -- or an assert silently skipped under `python -O` letting
a None reach the f-strings as the literal text "None" -- left the
session behind with nothing left to kill it.

what:
- Extract the identity-guard construction into
  _session_identity_predicate, replacing the bare asserts with
  explicit exc.LibTmuxException raises
- Call it in its own try/except that kills the session directly (no
  user code has run yet, so the reuse race the predicate itself
  guards against below cannot have happened) and re-raises
- Add a regression test simulating a session missing an identity field
why: _stop() sent SIGTERM then unconditionally waited up to 5 seconds
before falling back to SIGKILL. A client stopped by SIGSTOP cannot
process SIGTERM while stopped, so against a stopped client that wait
always ran its full 5 seconds -- the parametrized
test_control_mode_cleanup[stopped] case paid this on every run, a
structural wait with no slow marker or documented reason.

what:
- Send SIGCONT (ignoring ProcessLookupError) right after terminate(),
  so a stopped client can actually see the pending SIGTERM and exit
  promptly instead of guaranteeing the wait times out; a running
  client just ignores the extra signal
- test_control_mode_cleanup[stopped] now completes in well under a
  second instead of 5+, so it needs no slow marker
…und names

why: owned/socket_path were bound only inside the with-body. An earlier
failure before either line ran (e.g. new_session raising something
other than the PermissionError the test injects) left them unbound, so
the finally block's owned.kill() or shutil.rmtree(socket_path.parent)
raised UnboundLocalError -- replacing the real failure as what the
test reports, skipping the kill, and leaking the daemon and its temp
directory.

what:
- Pre-declare owned/socket_path as None before the try
- Guard the finally's kill and rmtree on each being set
why: The body passed timeout=0.3 and asserted TmuxTimeout -- the
bounded path, not the None case the name and docstring claim. It
would pass identically whether or not a bare timeout=None call ever
waited, so a regression there (e.g. None silently getting some
default bound) would go undetected.

what:
- Run the call on a thread; assert it is still running past a bound
  well under the stub's 30s sleep, proving it did not raise early
- Kill the stub directly (bypassing libtmux's own timeout/kill path,
  which is what this asserts was never invoked) to let the thread
  return without the test itself waiting on the full sleep
- Assert the call completed without raising TmuxTimeout
why: Every other configuration attribute (socket_name, socket_path,
tmux_bin, ...) is declared at class level with a default, so an
instance built without going through __init__ -- object.__new__, or a
subclass whose __init__ skips super().__init__() -- still has a value
to read. timeout was assigned only inside __init__, so that same
construction path raised AttributeError on first use.

what:
- Add `timeout: float | None = None` alongside tmux_bin
- Add a regression test constructing a Server via object.__new__
…nstructor

why: owned() built socket_path as a pathlib.Path and passed it straight
through. __init__ stored it as-is, so an owned server's socket_path
was a Path while every other Server constructor only ever produces a
str. __eq__ compares socket_path by value, and Path("/x") != "/x", so
an owned server never equaled the same endpoint addressed by string.

what:
- Coerce socket_path to str in __init__, mirroring the existing
  tmux_bin coercion, instead of special-casing owned()
- Add a regression test comparing an owned server to the same endpoint
  constructed from str(owned.socket_path)
why: The finding-5 regression test's replacement new_session took
*args/**kwargs typed as object, but real_new_session's actual
parameters are typed narrower (str | None, bool, StrPath | None, ...),
so mypy rejected forwarding them. new_session's own signature already
types its *args/**kwargs as t.Any for the same reason.

what:
- Type the stub's *args/**kwargs as t.Any, matching new_session
why: `timeout=self.timeout if timeout is None else timeout` treated an
explicit `timeout=None` the same as an omitted argument, both
defaulting to None, so a caller could never opt one command out of a
server-wide timeout -- the override always collapsed back onto
Server.timeout.

what:
- Add a private _NotSet sentinel and default `timeout` to it instead
  of None, so cmd() can tell "not passed" from "passed as None"
- Document the three states (omitted / None / a number) in cmd()'s
  docstring
- Add tests: omitting timeout uses the server's bound; an explicit
  timeout=None runs the call unbounded even though the server has one
test_control_mode_cleanup[stopped] never actually exercised whether
_stop() sends SIGCONT: removing it still passes the test, just five
seconds slower, because the wait(timeout=5)/kill() fallback reaps the
process regardless. Assert elapsed time stays well under that fallback
so a dropped SIGCONT fails the test instead of only slowing it down.

Verified by reverting the SIGCONT call locally: the test now fails at
5.01s with the intended message, and passes at 0.36s with the call
restored.
Starting the clock before entering the with-block also counted Popen
and the client_registered retry loop, which are unrelated to the
SIGCONT path and could eat into the 2s margin under CPU contention.
Move the start to the last line inside the block, immediately before
__exit__ runs, so elapsed measures only _stop() itself.

Re-verified the same way as the prior commit: reverting SIGCONT still
fails at ~5.00s, restoring it passes.
why: tmux's server-access arg spec ("adlrw", 0, 1) declares every
letter flag value-less and takes the user as a single trailing
positional (usage: "[-adlrw] [user]"). server_access() built
`-a <user> -r`: once tmux's getopt-style parser reaches the bare
username right after -a, it stops recognizing further "-" tokens as
flags and reads "-r" as a second positional, rejecting the whole call
as "too many arguments" -- -r/-w silently never applied whenever
combined with allow/deny.

what:
- Collect the target user separately and append it once, after every
  boolean flag (-a/-d/-l/-r/-w)
- test_server_access_argv's stubbed argv assertions encoded the old,
  wrong order; it never caught this because it never exercised real
  tmux. Corrected to `(-a, -r, alice)` / `(-a, -w, bob)`
- Added test_server_access_flags_precede_positional_user against a
  real tmux: the suite has no second real OS user to allow (tmux
  refuses to touch the server owner's own entry), so it proves the
  fix by reaching tmux's *next* validation step -- an unknown-user
  lookup -- instead of failing on argv shape first. Reverting the fix
  reproduces "too many arguments" on this test and the wrong tuples on
  test_server_access_argv; both pass again restored.

Found while auditing tmux 3.8's server-access -l U/G markers for this
round's format-change sweep -- unrelated to those markers, but the
same code path.
tmux 3.8 changed four format outputs; this round's 1553-pass next-3.9
run exercised the suite as it stood but didn't pin these two
properties as regression tests:

- #{pane_pid} is now an empty string, not "0", for a pane whose
  process has already exited (libtmux-java crashed on exactly this).
  Confirmed live against the next-3.9 probe binary: a dead pane's pid
  goes from numeric (tmux 3.7d) to "" (next-3.9). libtmux never calls
  int() on pane_pid -- verified across src/ -- so nothing needed
  fixing; test_dead_pane_pid_has_no_numeric_coercion pins that
  contract against a real dead pane on whichever tmux is under test.
- #{window_layout} is JSON for non-control clients on 3.8+, and
  select-layout accepts both forms with a byte-exact round trip
  (measured last round). Existing tests only compared layouts across
  next/previous-layout cycling; nothing fed a saved layout string
  straight back into select_layout(). Added
  test_select_layout_round_trip_is_byte_exact for that direct path;
  verified it fails when the restored layout is mutated, passes
  restored.

Two of the four format changes need no new coverage:
- #{q:...}'s widened escaping doesn't apply -- neo.py builds every
  format string as bare `#{field}` plus a private separator
  (FORMAT_SEPARATOR), never `#{q:...}`. libtmux does not decode q:
  escaping at all.
- server-access -l's new U/G markers: Server.server_access() returns
  proc.stdout verbatim with no parsing, so a marker it has never seen
  cannot break it (covered separately in the server_access argv-order
  fix in this branch).

Not touched: TMUX_MAX_VERSION ("3.7" in common.py) undershoots what
tmux's git master already reports, but the tmux source under study
(~/study/c/tmux) has only a 3.8-rc tag, no final 3.8 -- bumping it now
would claim support for a release that hasn't shipped. It only affects
two synthetic fallbacks (OpenBSD's no -V tmux, and a literal "master"
version string); has_gte_version()-style checks query the live binary
and are unaffected.
why: `continue-on-error: ${{ matrix.tmux-version == 'master' }}` made
the one CI lane built to catch a tmux behavior change before its
release unable to fail. A check that cannot fail is the CI-level form
of the same defect shape found five times in code this round. The
matrix already builds tmux from git master and runs it on every push
and PR -- this was suppressing signal, not saving cost.

Evidence for flipping now rather than deferring:
- addopts already sets --reruns=2, which is the tool for absorbing
  timing flakiness; continue-on-error at the job-step level duplicated
  that with a blunter instrument (swallows real failures too).
- Building tmux itself failing already hard-fails an earlier,
  unguarded step; this flag only ever shielded pytest failures.
- Checked the `Test with pytest` step's own conclusion (not just the
  job's rollup, which continue-on-error can mask) across this branch's
  last several pushes via `gh api .../actions/jobs/<id>` -- green on
  every one, against tmux's real git master, not a local probe.

Not independently provable as a negative test: this is CI policy, not
a runtime assertion, and deliberately breaking master tmux's build to
prove the gate can fail would mean shipping that breakage. The
falsifiable claim above is the recent step-level history, checked
directly against the GitHub API rather than assumed from the green job
badge.
… pytest

This is the reference implementation the other seven libtmux ports are
ported from, and it shipped nothing a reader could paste and run --
go has examples/ with each one compiled and tested in isolation, swift
has Examples/ with a check_examples.py gate, ts has examples/. python
had doctested snippets in docstrings and docs/ pages, which cover the
API surface but assume a fixture-provided server/session/pane already
in scope -- nothing a reader runs standalone.

what:
- 5 standalone scripts under examples/: quickstart (the Server ->
  Session -> Window -> Pane walkthrough), command_results
  (run_command()/CommandResult), owned_scopes (Server.owned() vs
  Server.owned_session()), resilient_automation (a bounded timeout,
  TmuxTimeout, and verifying pane state instead of assuming it),
  polling_for_changes (the answer to "how do I notice a change" --
  Session.windows re-queries tmux, so retry_until() over it is the
  supported pattern; sets up the ControlMode decision in the next
  commit)
- Every script uses Server.owned() for a private daemon, never the
  bare Server() the doctest_namespace substitutes for testing --
  running one of these as shown must never touch a reader's own
  default-socket session
- tests/test_examples.py runs each script as a real subprocess
  (`sys.executable <script>`), parametrized by discovering
  examples/*.py, plus a guard test that fails if the directory is ever
  emptied. examples/ is deliberately NOT added to `testpaths`: the
  docutils doctest collector would otherwise try to doctest these
  modules' own docstrings instead of just executing them once
- Every test here already requires a live tmux binary on PATH (no mock
  backend, per CONTRIBUTING.md), so these stay in the default `pytest`
  run rather than a separate tier; marked `examples` in pyproject.toml
  for the one-line reason rather than a budget split this project
  doesn't otherwise have. Full parametrized set: ~1.5s
- docs/topics/examples.md literalinclude's all five, linked from
  topics/index.md and README.md's topic list and quickstart section
- Added `examples` to `[tool.mypy] files` -- strict-typed like the
  rest of the package
- Full suite (1540 passed, 23 skipped) and `just build-docs` both
  clean after this change

Negative-test proof: mutated quickstart.py's marker string, confirmed
test_example_runs_cleanly[quickstart] fails on the real WaitTimeout
from the script's own retry_until(), restored, reran green. Separately
hit a real flake before that: window_command="sh" (not the
nonexistent `window_shell` kwarg -- new_session()'s **kwargs silently
swallowed that typo, mypy included, since it never runs the example)
was needed so the marker wait doesn't race a login shell's own rc
startup; 10/10 clean after the fix.
… why

why: the rubric names "use of async + control + streaming and
non-blockingness" as something to evaluate this library on, and
ControlMode was the closest thing here to a control-mode API -- but it
is a test fixture, not a streaming one. It spawns a real
`tmux -C attach-session` client so tests have one to assert against
(Server.list_clients(), popups needing a TTY-backed client); it never
parses %begin/%end blocks or dispatches %output/%window-add/etc. Public
docstrings were pointing readers at it by name anyway (Server's
display_menu, show_messages, display_message all said "e.g. via
ControlMode"), which promises a decoder this class doesn't have --
promoting it in that state would be worse than leaving it undecided.

Decision: stays internal. `libtmux._internal.control_mode.ControlMode`
already fails docs/topics/public-vs-internal.md's own mechanical
"leading underscore in the module path" test; this makes that
deliberate instead of incidental, states the reason (no protocol
decoding, not a partial streaming API with a rough edge), and commits
to starting a real control-mode client as a new module rather than a
promotion of this one, if that ever becomes a deliverable.

what:
- docs/topics/public-vs-internal.md: new section naming the three
  alternatives in order of reach -- polling (Session.windows /
  Window.panes, already always-fresh, wrapped in retry_until()),
  Pane.pipe() (pipe-pane; the closest thing to real streaming here),
  and hooks (server-side events, still not a Python callback). States
  plainly that libtmux has no asyncio anywhere in src/ and that
  send_keys() not blocking on completion is the one non-blocking
  primitive that already exists -- polling is how a caller finds out
  what happened next.
- pytest_plugin.py's control_mode fixture and control_mode.py's class
  docstring both get the same stability statement: the fixture is
  public plugin surface, the class underneath it is not.
- Server.display_menu/show_messages/display_message no longer point a
  public docstring at the internal class (one of these roles also had
  no autodoc page to resolve to, since control_mode.py isn't part of
  the internals API docs -- a dangling :class: role, not a working
  cross-reference). Reworded to describe attaching any real client.

Full suite (1540 passed, 23 skipped), mypy, ruff, and `just build-docs`
all clean after this change; the new cross-references resolve with no
new build warnings.
…decode

Six of eight libtmux ports have a benchmark suite; python and cxx did
not. Added `benchmarks/`, measuring what the rubric names: command
dispatch, listing, snapshot capture, format decoding.

what:
- benchmarks/bench_dispatch.py: Server.cmd() round trip
- benchmarks/bench_listing.py: Server.sessions/.windows/.panes across a
  populated session (8 windows x 4 panes)
- benchmarks/bench_capture.py: Pane.capture_pane() against a pane with
  200 lines of scrollback
- benchmarks/bench_format_decode.py: neo.parse_output() and
  neo._split_records() against a synthetic multi-record blob, no tmux
  process involved -- isolates decode cost from the subprocess round
  trip bench_listing.py measures
- `just bench` runs `pytest benchmarks/ -o python_files='bench_*.py'
  --benchmark-only`; benchmarks/ is not in `testpaths`, and pytest's
  default python_files pattern (test_*.py) would not otherwise collect
  bench_*.py files, so the override is load-bearing, not cosmetic
- pytest-benchmark added under a new `benchmark` dependency-group and
  layered into `dev`
- Added benchmarks/ to `[tool.mypy] files` -- strict-typed like
  examples/
- CONTRIBUTING.md documents the suite as a named, separate tier from
  the gates, matching this round's performance-work framing (not part
  of the test loops, no single run over the project's own budget)
- CHANGES entry describes what's measured without embedding numbers --
  a figure nothing re-verifies belongs in a commit message, not living
  prose that will silently drift

Not benchmarked: control-mode throughput. Per this round's ControlMode
decision (docs/topics/public-vs-internal.md), it decodes none of
tmux's protocol and is a private test fixture, not a public streaming
API -- there is no per-event decode cost to measure, and benchmarking
its raw internal pipe-read speed would suggest a capability that does
not exist. A real control-mode decoder, if one ships, gets its own
benchmark then.

Measured (this box, `just bench`, 7 tests, 9.44s total):

    test_bench_parse_output           mean   14.5us  (1 record, pure decode)
    test_bench_split_records          mean  520.2us  (64 records)
    test_bench_command_dispatch       mean    1.55ms (1 round trip)
    test_bench_capture_pane           mean    1.96ms (200-line pane)
    test_bench_server_sessions        mean    4.42ms (1 session)
    test_bench_server_windows         mean    5.27ms (8 windows)
    test_bench_server_panes           mean   11.57ms (32 panes)

Full suite (1540 passed, 23 skipped) unaffected -- benchmarks/ is
outside testpaths, confirmed by an unchanged pass count before and
after. `just build-docs` clean; mypy and ruff clean on benchmarks/.
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