Conversation
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.
tony
force-pushed
the
api-improvements
branch
from
September 13, 2026 11:15
0577ef9 to
b70ea7a
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #758 +/- ##
===========================================
+ Coverage 52.37% 81.87% +29.50%
===========================================
Files 26 28 +2
Lines 3729 3928 +199
Branches 747 761 +14
===========================================
+ Hits 1953 3216 +1263
+ Misses 1472 407 -1065
- Partials 304 305 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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
force-pushed
the
api-improvements
branch
from
September 13, 2026 11:58
b70ea7a to
79a4266
Compare
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
tony
force-pushed
the
api-improvements
branch
from
September 13, 2026 16:05
c8eb8b2 to
9c01095
Compare
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
…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/.
…s with --
select_layout("-o") ran as tmux's own undo flag instead of a layout
value (PY-1): any caller-supplied string starting with "-" became a
flag. Insert "--" before the layout argument so tmux always reads it
as the layout, matching raw `select-layout -t w -- -o` refusing with
"invalid layout: -o".
select_layout("") silently re-applied the current layout, identical to
omitting the argument (PY-3). An explicit empty string is now refused
with ValueError; pass layout=None to omit it.
send_keys() and capture_pane() never inspected their tmux command's
result: a killed pane made send_keys() return None and capture_pane()
return [] with no indication anything went wrong, while refresh() on
the same handle already raised TmuxObjectDoesNotExist (PY-5). Both, and
enter(), now raise LibTmuxException carrying tmux's own stderr
("can't find pane: ..."), matching every other typed method.
Added Pane.left_cells/top_cells, decoded int properties for pane_left/
pane_top (PY-4), matching the existing width_cells/height_cells
convention -- a consumer verifying layout geometry no longer needs to
int() the raw fields by hand.
test_capture_pane_flag_smoke's alternate_screen case now pairs with
quiet=True: tmux's own `capture-pane -a` exits 1 ("no alternate
screen") off the alternate screen, which now raises instead of being
silently absorbed. A dedicated negative test covers that raise.
is_dead already read a cached snapshot (documented in one line as "reads locally"), but a consumer polling it to learn a command finished got False forever regardless (PY-6): the property name promises a live answer the implementation never gives, and without remain-on-exit there is no "dead" state to read at all -- tmux destroys the pane outright, and refresh() raises TmuxObjectDoesNotExist rather than reporting is_dead=True. The docstring now shows the three cases an Examples doctest can prove: a stale handle answering from its last snapshot, refresh() raising on a pane tmux fully destroyed, and refresh() correctly reporting is_dead=True when remain-on-exit kept it around.
window_layout + select_layout reads as an unqualified save/restore round trip, but pane *identity* is only guaranteed on tmux 3.8+ (PY-2): before 3.8, restoring a saved classic-form layout string can rotate which pane lands in which cell even though the resulting arrangement is identical -- confirmed against raw tmux on 3.2a/3.7c/master. select_layout's docstring now states this plainly. Added a positive-proof test pinning the 3.8+ guarantee: since python exposes no public control-mode client, every reader gets JSON (which carries pane ids), so a saved-then-restored layout puts every pane back at its original (left, top) -- using the new left_cells/top_cells properties. The <3.8 rotation is tmux's own behavior, arrangement- dependent, and not independently pinned here; the existing byte-exact string round-trip test already covers what's deterministic pre-3.8.
wait_for(channel, lock, unlock, set_flag) had no way to bound the wait (PY-7): Server.owned()/Server() default Server.timeout to None (unbounded), and the method's own signature offered no per-call override, so an unsignalled channel -- the primitive this project recommends for gating a command's exit -- blocked the caller forever with no escape. wait_for now accepts timeout, threaded through to Server.cmd exactly like Server.cmd's own override: omit for Server.timeout, None for unbounded, a number to bound just this call. Raises TmuxTimeout on expiry. set_flag= is renamed to signal= (PY-8), tmux's own manual's name for `wait-for -S`; set_flag still works as a deprecated alias emitting DeprecationWarning.
…how much server.sessions == [] cannot distinguish "no sessions" from "tmux unreachable" (documented), but a caller could not tell that a Session/Window relation obtained beforehand disagrees on which way to fail (PY-9). Reading the actual implementations: Server.sessions/ .clients/Window.linked_sessions swallow any LibTmuxException; Server.windows/.panes are lenient only for a not-yet-started daemon or missing socket, propagating everything else; Session.windows/.panes, Window.panes, and Window.search_panes are not lenient at all. src/libtmux/AGENTS.md's "List-returning accessors" section now states all three tiers precisely, and every affected docstring cross- references it. Added a pin: killing a real server leaves server.sessions/.windows/.panes empty, but session.windows and window.panes obtained beforehand raise LibTmuxException -- the exact trap the finding described. This is deliberate; no behavior changed.
…ault The stderr-probe called run_command() with no socket selector, so it fell through to \$TMUX_TMPDIR/tmux-<uid>/default -- the reader's own interactive tmux, if one happens to be running (PY-10). Read-only and has-session never starts a server, so this was harmless, but it contradicted examples/'s stated property of never touching a session the reader already has open, and its printed output changed depending on whether the reader was running tmux. Names an -L socket that cannot already exist, so "no server running" is deterministic regardless of the reader's own tmux state. Added a dedicated test asserting the isolated socket name appears in stdout and the ambient default socket never does.
uv run pytest benchmarks/ collected 0 items and exited 0 (PY-11):
pytest's default python_files ('test_*.py') never matches this
directory's bench_*.py files, which reads as "ran fine, nothing to
benchmark" rather than "wrong invocation" -- benchmarks/ is
deliberately outside testpaths and not part of the gates.
Root conftest.py's pytest_collection_modifyitems now raises
pytest.UsageError naming `just bench` when an invocation that names
benchmarks/ directly collects zero items; a broader scan that merely
walks through the directory (pytest ., a bare pytest) is unaffected,
verified by collect-only counts before and after. Tried putting the
hook in benchmarks/conftest.py first: that collides with the project's
own root conftest.py under pytest's default (non-package) import mode
whenever a run's collection tree includes both, so the hook lives in
the one conftest.py that already exists.
Updated each bench_*.py's own "Run with::" comment, which named the
exact broken invocation.
Server.owned()'s cleanup lived only in the context manager's own finally, which runs on KeyboardInterrupt (Python already turns SIGINT into that) but not on SIGTERM (timeout, kill, a cancelled CI job, docker stop, systemd) or SIGHUP (closing the terminal) -- their default disposition ends the interpreter without unwinding, leaking the private tmux daemon and its socket directory (PY-12). owned() now installs a handler for both signals, only where nothing already handles or ignores them (a caller's own handler or an explicit SIG_IGN is left alone) and only on the main thread; the handler raises SystemExit(128 + signum), an ordinary exception the same finally already unwinds through, converging with the existing SIGINT/exception paths instead of duplicating cleanup logic. The process still ends -- with the shell's own convention for a signal-killed exit code -- just after the daemon and directory are gone. The previous handler is restored on exit, and only if the block did not itself replace it. Regression test drives a real child process end to end: spawns it, waits for it to report its socket_path, sends the real SIGTERM/SIGHUP, and asserts both the exit code and that the daemon and socket directory are gone. Confirmed leaking (raw signal exit code, live daemon left on disk) with the fix reverted; cleaned up both leaked daemons by hand before restoring the fix.
….12) CHANGES entries for every observable behavior or documentation change in this round: select_layout's -- guard and empty-string refusal, send_keys/capture_pane/enter raising on tmux failure, wait_for's timeout and signal= rename, the command_results.py socket fix, Server .owned's signal handling, the pane left_cells/top_cells properties, the select_layout round-trip and is_dead documentation, the list-accessor leniency documentation, and the benchmarks/ collection guard.
…en a race margin Root AGENTS.md still said "list-shaped accessors are lenient by default" as an unqualified blanket statement -- the exact over-generalization PY-9 is about, and the first thing an agent reads. States the tiers directly and points to src/libtmux/AGENTS.md for the full contract. Pane.is_dead's doctest used a 0.2s sleep before the pane's process exited, racing Window.split()'s own read-back under load: it flaked ~50% of ~20 runs against tmux's git master with a peer session building nearby, always on the same line (Window.split -> Pane.split -> from_pane_id's follow-up list-panes, not the doctest's own code). Widened to 1s (retry budget to 3s to match); 10/10 clean afterward on the same binary under the same load.
…roof
CI failed: test_just_bench_still_collects_and_runs's subprocess
inherited its own xdist worker's environment (CONTRIBUTING.md's
coverage gate runs `pytest -n auto`), which makes pytest-benchmark
auto-activate --benchmark-disable and then refuse to run alongside an
explicit --benchmark-only ("Can't have both --benchmark-only and
--benchmark-disable options"). --collect-only never runs the
benchmarks in the first place, so --benchmark-only was never needed to
prove discovery; dropped it. Reproduced the exact CI failure locally
under `pytest -n auto` before this fix, confirmed green after, and
confirmed the full `coverage run -m pytest -n auto` invocation CI uses
passes end to end.
…sees it why: 634b6b1 guarded select_layout("-o") by passing tmux's "--" separator, so "-o" is read as a layout instead of tmux's undo flag. On tmux 3.3 and 3.3a that is not enough: forced through as a layout string, "-o" is an invalid layout, and those releases free an uninitialised pointer on any invalid layout string, killing the whole server. Verified on raw tmux, `select-layout -t p -- -o`: 3.2a can't set layout: -o server alive 3.3a server exited unexpectedly server dead 3.4 invalid layout: -o server alive 3.7c invalid layout: -o server alive what: - Raise ValueError for a layout beginning with "-" before dispatch. No valid layout begins with "-": presets are alphabetic, the classic form starts with a checksum, JSON starts with "{" - Keep "--" as defence in depth for a value that reaches tmux another way - Pin tmux's own 3.3/3.3a crash in a separate test that skips elsewhere - Update the CHANGES entry, which described the "--" guard alone Negative test, on tmux 3.3a: with the client-side refusal disabled, test_select_layout_dash_o_is_a_layout_not_the_undo_flag fails with `LibTmuxException: select-layout: server exited unexpectedly`; restored, tests/test_window.py passes (87 passed, 2 skipped) on 3.3a and 3.7c.
The leading-dash guard covered '-o' and left every other unparseable value going through, including 'garbage' and an unknown preset name. On tmux 3.3a each of those exits the daemon and takes every session on the socket with it. select_layout now accepts a preset name, tmux's classic checksum-prefixed string, or JSON, and raises VersionTooLow for a mirrored preset below 3.5 or JSON below 3.8. Without the check the new tests fail on 3.3a with the server gone (six failures); they pass on 3.3a, 3.7c and tmux master.
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
run_command()andCommandResultfor explicit process execution and captured results, while retaining thetmux_cmdcompatibility facade.TmuxTimeout, including through otherwise lenient listings.Server.owned()or a newly created session withServer.owned_session(). Session cleanup verifies session and daemon identity; both scopes expose cleanup failures.QueryListimport with precise required/defaulted lookups, plus local numeric and boolean properties that retain the raw tmux fields.Command results
The compatibility constructor remains available:
Callers can select the explicit execution function:
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
Related work
The branch includes the command-timeout support proposed in PR #757.
Remediation round (2026-09-15)
Server.server_access()emitting flags after the positionaluser (
-a alice -r), which tmux's own arg parser read as two positionalarguments and rejected as "too many arguments" --
-r/-wsilentlynever applied whenever combined with
allow/deny. Flags now precedethe user.
master-tmux CI matrix lane on its ownTest with pyteststep, dropping
continue-on-error. Verified green against tmux's realgit
masteracross recent pushes before flipping.examples/directory --python examples/quickstart.pyand friends, each using
Server.owned(), executed by the test suite asreal subprocesses (
tests/test_examples.py), documented atdocs/topics/examples.md.ControlModestays internal: it decodes none of tmux'scontrol-mode protocol and is a test-only client, not a streaming API.
docs/topics/public-vs-internal.mdstates this and names thealternatives (polling,
Pane.pipe(), hooks); public docstrings thatpointed at the internal class by name were reworded.
benchmarks/suite (just bench, pytest-benchmark) coveringcommand dispatch, listing, snapshot capture, and format decoding --
a separate tier from the gates, not part of
pytest's default run.#{pane_pid}(now empty, not"0") never reaches a numeric coercion,and a saved
#{window_layout}round-trips byte-exact throughselect_layout().Hand-test fix round (2026-09-16)
Window.select_layout()sending a value tmux cannot parse.select_layout("-o")silently ran tmux's own undo flag, and any otherunparseable value reached tmux; on tmux 3.3 and 3.3a such a value exits
the server and destroys every session on the socket, and
--is whatturns
-ointo one. Only a preset name, a classic checksum-prefixedlayout, or JSON is accepted, with
VersionTooLowfor a mirrored presetbelow 3.5 and JSON below 3.8;
--stays as defence in depth. Verifiedagainst 3.3a, 3.7c and git
master: without the check the tests failthere with the server gone. An explicit empty string is refused
(
ValueError) instead of behaving likeNone.Pane.send_keys(),Pane.capture_pane(), andPane.enter()silently succeeding on a killed pane (
send_keysreturnedNone,capture_panereturned[]) instead of raising, unlikerefresh()onthe same handle. All three now raise
LibTmuxExceptioncarrying tmux'sown stderr.
Server.wait_for()having no way to bound the wait -- anunsignalled channel blocked forever. Added a
timeoutparameter,forwarded to
Server.cmd()'s own override. Renamedset_flag=tosignal=(tmux's own name forwait-for -S);set_flagremains as adeprecated alias.
Server.owned()leaking its private daemon and socketdirectory when the owning process receives
SIGTERMorSIGHUP(
timeout,kill, a cancelled CI job,docker stop, systemd, closingthe terminal) -- only
SIGINT/KeyboardInterruptran cleanup before.Traps both signals, only where nothing already handles or ignores them
and only on the main thread, converting them into the same exception
path the existing cleanup already handles. Regression test drives a
real child process and a real signal end to end.
Pane.left_cells/Pane.top_cells, decoded int properties forpane_left/pane_top, matching the existingwidth_cells/height_cells.examples/command_results.pysilently probing the reader'sown default tmux socket when no explicit selector was given; now names
an isolated socket that cannot already exist.
pytest benchmarks/silently collecting 0 items and exiting0. The root
conftest.pynow raises a clearUsageErrornamingjust benchwhenever an invocation that namesbenchmarks/directlycollects nothing.
select_layout's round-trip identity guarantee: exactpane-identity restore needs tmux 3.8+ (every libtmux reader gets JSON
there); before 3.8 the shape restores exactly but pane identity can
rotate. Proven for the 3.8+ case with a dedicated test.
Pane.is_dead's local-snapshot contract with athree-case doctest: a stale handle, a pane destroyed outright (no
remain-on-exit), and one correctly reporting dead (with it).(
Server.sessions/.clients/.windows/.panes,Window.linked_sessions,Session.windows/.panes,Window.panes/.search_panes) are lenienton a tmux failure and how much -- they disagree, and
Server.sessions == []on a dead server never implies aSession/Windowrelationobtained beforehand will also read empty rather than raise. Added a
pinning test against a real killed server.
Every fix above ships a negative test proven to fail for the intended
reason before the fix, and passes across tmux 3.2a, 3.7c, and git
master.