Skip to content

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

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

tony wants to merge 69 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().

Hand-test fix round (2026-09-16)

  • Fixed Window.select_layout() sending a value tmux cannot parse.
    select_layout("-o") silently ran tmux's own undo flag, and any other
    unparseable 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 what
    turns -o into one. Only a preset name, a classic checksum-prefixed
    layout, or JSON is accepted, with VersionTooLow for a mirrored preset
    below 3.5 and JSON below 3.8; -- stays as defence in depth. Verified
    against 3.3a, 3.7c and git master: without the check the tests fail
    there with the server gone. An explicit empty string is refused
    (ValueError) instead of behaving like None.
  • Fixed Pane.send_keys(), Pane.capture_pane(), and Pane.enter()
    silently succeeding on a killed pane (send_keys returned None,
    capture_pane returned []) instead of raising, unlike refresh() on
    the same handle. All three now raise LibTmuxException carrying tmux's
    own stderr.
  • Fixed Server.wait_for() having no way to bound the wait -- an
    unsignalled channel blocked forever. Added a timeout parameter,
    forwarded to Server.cmd()'s own override. Renamed set_flag= to
    signal= (tmux's own name for wait-for -S); set_flag remains as a
    deprecated alias.
  • Fixed Server.owned() leaking its private daemon and socket
    directory when the owning process receives SIGTERM or SIGHUP
    (timeout, kill, a cancelled CI job, docker stop, systemd, closing
    the terminal) -- only SIGINT/KeyboardInterrupt ran 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.
  • Added Pane.left_cells/Pane.top_cells, decoded int properties for
    pane_left/pane_top, matching the existing width_cells/height_cells.
  • Fixed examples/command_results.py silently probing the reader's
    own default tmux socket when no explicit selector was given; now names
    an isolated socket that cannot already exist.
  • Fixed pytest benchmarks/ silently collecting 0 items and exiting
    0. The root conftest.py now raises a clear UsageError naming
    just bench whenever an invocation that names benchmarks/ directly
    collects nothing.
  • Documented select_layout's round-trip identity guarantee: exact
    pane-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.
  • Documented Pane.is_dead's local-snapshot contract with a
    three-case doctest: a stale handle, a pane destroyed outright (no
    remain-on-exit), and one correctly reporting dead (with it).
  • Documented precisely which list-returning relations
    (Server.sessions/.clients/.windows/.panes, Window.linked_sessions,
    Session.windows/.panes, Window.panes/.search_panes) are lenient
    on a tmux failure and how much -- they disagree, and Server.sessions == [] on a dead server never implies a Session/Window relation
    obtained 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.

@codecov

codecov Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.23188% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.90%. Comparing base (76c50c5) to head (9044cc4).

Files with missing lines Patch % Lines
src/libtmux/server.py 90.90% 6 Missing and 5 partials ⚠️
src/libtmux/common.py 96.77% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master     #758       +/-   ##
===========================================
+ Coverage   52.37%   82.90%   +30.53%     
===========================================
  Files          26       28        +2     
  Lines        3729     3954      +225     
  Branches      747      767       +20     
===========================================
+ Hits         1953     3278     +1325     
+ Misses       1472      382     -1090     
+ Partials      304      294       -10     

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

@tony
tony force-pushed the api-improvements branch 5 times, most recently from acedbce to dcf1ddc Compare September 13, 2026 18:07
tony added 23 commits September 19, 2026 08:08
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, so a completed cleanup failure stays visible and
  the endpoint remains available for retry, without changing legacy
  Server.kill behavior
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
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. Joined captures on older tmux also pad completed marker lines,
which can time out examples and capture tests waiting for an exact
match.

what:
- Match complete output lines and use fresh markers for repeated work
- Compare completed marker lines after removing right-padding spaces,
  and reject echoed or stale markers
- 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: 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
tony added 13 commits September 19, 2026 08:19
…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: 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. 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. 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, 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.
window_layout + select_layout reads as an unqualified save/restore
round trip, but pane *identity* is only guaranteed on tmux 3.8+: 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.
why: wait_for(channel, lock, unlock, set_flag) had no way to bound the
wait: 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.

what:
- Add 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
- Rename set_flag= to signal=, tmux's own manual's name for
  `wait-for -S`; set_flag still works as a deprecated alias emitting
  DeprecationWarning
- Reject a timeout of zero or less in the shared run_command
  chokepoint every Server.cmd() call and wait_for() go through:
  subprocess.Popen.communicate(timeout=0) (or a negative value) never
  gives the freshly spawned tmux process a chance to respond, so it
  always reads as expired and silently dropped a signal=True call
  before wait-for ever ran
- Document a tmux limitation the bounded lock wait makes reachable for
  the first time: a timed-out lock wait does not give the lock back,
  so tmux hands it to the next queued locker on unlock and every later
  locker on that channel also times out (cmd-wait-for.c's
  cmd_wait_for_unlock) -- use a fresh channel name after a timed-out
  lock wait
…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. 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. This is
deliberate; no behavior changed.
why: 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.

what:
- Install 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
- Run this method's own cleanup directly from the handler, then
  restore the signal's default disposition and re-raise it against the
  process with os.kill, so the process dies by the signal (a parent
  sees a signal exit, e.g. -15, not exit code 143/129) -- no exception
  is raised for the block to unwind through, so a broad except
  anywhere inside it cannot catch it and keep the process running past
  its signal
- Make _cleanup() a standalone, re-callable closure, so calling it
  once from the handler and again from the generator's own finally is
  safe either way
- The previous handler is restored on exit, 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. A second test proves a bare except wrapping the
  block's own code cannot keep the process running past the signal
…en a race margin

Root AGENTS.md still said "list-shaped accessors are lenient by
default" as an unqualified blanket statement -- an over-generalization,
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.
…sees it

why: "Window(fix[select_layout]): Refuse an empty layout, guard hostile
ones with --" 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.
parse_output drops empty values from a list-* row, and Obj._refresh
setattr'd only the keys present in that filtered row -- a field that
went from set to empty (a title cleared with select-pane -T '', a dead
pane's #{pane_pid} on tmux 3.8+) kept its last non-empty value forever
instead of matching a fresh query for the same object.

_refresh now iterates the full field set get_output_format resolves for
the live list_cmd/version and sets every one of them, clearing to None
when a field is absent from the row rather than skipping it. Pinned by
a regression test asserting a field cleared between queries reads back
as None.
…cess or a field dump

reset() discarded its send-keys/clear-history command's result and
returned the Pane unchanged on a killed pane, silently "succeeding"
where send_keys, clear, enter, and resize already raise. It now checks
the result like those do.

split()'s failure path built LibTmuxException(stderr, self.__dict__,
self.window.panes), so str(exc) embedded every field of the dead pane
and its live siblings -- nearly 5 KB for what raw tmux reports in one
line. Replaced with raise_if_stderr(pane_cmd, "split-window"), the same
helper new_pane() already uses, so the message matches every other
typed method's shape. Both are pinned by regression tests asserting
each raises on a killed pane.
"Run any tmux command" called a bare Server(socket_name=...), which
only resolves during this project's own doctest run: conftest.py's
add_doctest_fixtures rebinds Server to a test factory there. Copied
into a fresh interpreter, as the README's own framing promises a
reader can, it raised NameError: name 'Server' is not defined.

The snippet now imports the real class and uses it as a context
manager, so it runs identically inside the suite and pasted into a
plain python shell, and cleans up its own daemon either way instead of
depending on the doctest factory's cleanup registration.

tests/test_readme.py extracts the block straight from README.md (not a
hand-copied duplicate) and runs it with plain python -c, outside pytest
and its injected doctest namespace entirely, so a bare Server(...)
regressing here fails a test in this suite, not only a reader's own
copy-paste.
@tony
tony force-pushed the api-improvements branch 2 times, most recently from 2bd2ad9 to c3d4a67 Compare September 19, 2026 14:09
why: Ten lines this branch added had no test: the identity guard that
replaced a bare assert, the scope opened where signals cannot be
installed, server-access's flag order, the kill that bounds a wedged
client's shutdown, a layout prefix naming a preset the running tmux
predates, and a refused copy-mode command.

what:
- Name the withheld field for each of the three identity guards
- Yield and clean up a scope when getsignal or signal refuses
- Put -d before the positional user, as -a already was
- Kill a client whose terminate and SIGCONT both go unanswered
- Refuse main-vertical-m below 3.5 and apply it from 3.5
- Raise a copy-mode refusal instead of returning quietly

Each was shown to fail first: breaking its behaviour fails the test,
restoring it passes.
why: The flag-order test calls server_access, which refuses below tmux
3.3 before building any argv, so the 3.2a lane failed on the guard
rather than on the order it checks. Its sibling already skips there.

what:
- Skip the test below tmux 3.3, as the neighbouring case does
why: _terminate() ran _cleanup() then os.kill() sequentially, so a
cleanup failure (e.g. kill-server itself erroring) raised instead of
reaching os.kill(). The exception became the exit path in the running
block, letting a broad except there observe and outlive the signal --
exactly what owned()'s docstring says cannot happen.

what:
- Wrap _cleanup() in try/except inside _terminate, log on failure, and
  always re-raise the trapped signal via os.kill()
- Trim the docstring's Notes to state the new behavior and drop
  branch-internal narrative about a prior in-branch state
- Add a regression test that fails a simulated kill-server call and
  asserts the child still exits by signal
why: conftest.py seeds doctest_namespace with retry_until, and pane.py's
is_dead example already calls it directly, but WRITING.md's list of
names a doctest can reach without an import never mentioned it.

what:
- Add retry_until to the doctest_namespace name list
why: WRITING.md caps a source comment at one or two lines, one fact
each. _terminate's own comment ran five lines across two facts, and
select_layout's "--" comment argued a "defense in depth" case instead
of stating the constraint.

what:
- Split _terminate's comment into three single-fact comments, one per
  statement it documents
- Cut select_layout's "--" comment to the parsing fact
why: owned()'s docstring covers SIGTERM/SIGHUP handling in isolation but
never says what happens when that isolation breaks: owned_session() has
no signal trap of its own, and a nested owned() scope installs nothing
because the outer scope's handler is already there. Both leak the inner
resource on a signal, silently, with nothing in either docstring to warn
a caller relying on the sibling method's guarantee.

what:
- owned_session(): note that it traps no signal, unlike owned(), and
  that a SIGTERM/SIGHUP during the block leaks the session
- owned(): note that a nested scope installs no handler of its own, so a
  signal during the inner block only runs the outer cleanup
why: The warning claimed a channel wedged by a timed-out queued lock
wait can never be locked again. tmux's cmd_wait_for_unlock only
clears a channel when nothing is queued behind it; verified against
tmux 3.2a that one extra unlock call (per abandoned locker) does
recover it, so "no way to lock it again" overstated the failure.

what:
- Describe the actual mechanism: unlock hands off to a queued locker
  instead of clearing the flag, whether or not that locker is still
  alive
- State the real recovery path (one extra unlock per abandoned
  locker) and why it is impractical to rely on
why: test_test_server_cleanup slept 0.1s after kill() and then checked
a second, unrelated TestServer's is_alive() -- a fresh server object
correctly reads as not alive before anyone starts it, so the assertion
held even with the sleep and second-server construction removed
entirely, proving it never exercised cleanup on the original server.

what:
- Assert server.is_alive() is False on the same instance right after
  kill(), no sleep
- Drop the unrelated second-server construction and its assertions
why: An exact per-line match missed the marker whenever tmux's redraw
landed the next prompt on the same row right after it, with no line
break in between -- confirmed by running the script 15 times: 4 runs
raised WaitTimeout on that exact-match check.

what:
- Match on a stripped line ending in the marker, not full equality
- Exclude the echoed command line itself via a substring check for
  "echo" so the wait cannot pass before the shell has actually run it
@tony
tony force-pushed the api-improvements branch 3 times, most recently from ac8d109 to 9eee76a Compare September 19, 2026 23:46
Caller text beginning with a dash could reset panes, skip shell
commands or select a different tmux operation.

End option parsing before typed positional arguments. Cover the
command families with real-tmux regressions and retain the raw cmd
API and existing layout validation.
why: Readers need concise guidance for the final API behavior.

what:
- Describe observable changes and caller migrations
- Preserve existing notes and release history
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