diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7c0edbe84a..7aa1409e8f 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -62,6 +62,16 @@ Test: $ uv run pytest ``` +Start coverage before pytest so plugin imports and worker processes are +measured: + +```console +$ uv run coverage erase && \ + uv run coverage run -m pytest -n auto && \ + uv run coverage combine && \ + uv run coverage xml +``` + Documentation is a gate, not a courtesy. Examples in docstrings, documentation pages, and `README.md` are executed by `pytest`; the doctest flags live in `pyproject.toml`, so there is no separate doctest @@ -128,6 +138,24 @@ Include doctests in the watch loop: $ uv run ptw . --now --doctest-modules ``` +## Benchmarks + +`benchmarks/` holds [pytest-benchmark] microbenchmarks for command +dispatch, listing, snapshot capture, and format decoding. It is not a +gate — performance work is a separate tier from the gates above, not +part of them — and it is not in `testpaths`, so a plain `uv run pytest` +never runs it. + +```console +$ just bench +``` + +Report a regression with the printed numbers, not a guess. A number +that lands in a commit message or `CHANGES` is a measurement someone +ran, not a target to defend in the next one. + +[pytest-benchmark]: https://pytest-benchmark.readthedocs.io/ + ## Debugging Stuck in a debugging loop: pause and acknowledge it rather than trying diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d7d18a903b..79d1bfc58f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,6 +20,7 @@ jobs: uses: astral-sh/setup-uv@v10.0.1 with: enable-cache: true + save-cache: ${{ strategy.job-index == 0 }} - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} @@ -36,24 +37,41 @@ jobs: - name: Install dependencies run: uv sync --all-extras --dev + - name: Resolve tmux revision + id: tmux-source + env: + TMUX_REF: ${{ matrix.tmux-version }} + run: | + if [[ "$TMUX_REF" == master ]]; then + ref=refs/heads/master + else + ref="refs/tags/$TMUX_REF" + fi + revision=$(git ls-remote --refs https://github.com/tmux/tmux.git "$ref") + revision=${revision%%[[:space:]]*} + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + - name: Setup tmux build cache for tmux ${{ matrix.tmux-version }} id: tmux-build-cache uses: actions/cache@v6 with: path: ~/tmux-builds/tmux-${{ matrix.tmux-version }} - key: tmux-${{ matrix.tmux-version }} + key: tmux-${{ runner.os }}-${{ runner.arch }}-${{ matrix.tmux-version }}-${{ steps.tmux-source.outputs.revision }} - name: Build tmux ${{ matrix.tmux-version }} if: steps.tmux-build-cache.outputs.cache-hit != 'true' + env: + TMUX_REVISION: ${{ steps.tmux-source.outputs.revision }} run: | sudo apt install libevent-dev libncurses5-dev libtinfo-dev libutempter-dev bison mkdir ~/tmux-builds mkdir ~/tmux-src git clone https://github.com/tmux/tmux.git ~/tmux-src/tmux-${{ matrix.tmux-version }} cd ~/tmux-src/tmux-${{ matrix.tmux-version }} - git checkout ${{ matrix.tmux-version }} + git checkout "$TMUX_REVISION" sh autogen.sh - ./configure --prefix=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }} && make && make install + ./configure --prefix="$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}" && make && make install export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH cd ~ tmux -V @@ -73,17 +91,15 @@ jobs: uv run python -V - name: Test with pytest - continue-on-error: ${{ matrix.tmux-version == 'master' }} run: | sudo apt install libevent-2.1-7 export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH - ls $HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin + ls "$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin" tmux -V - uv run py.test --cov=./ --cov-append --cov-report=xml -n auto --verbose - env: - COV_CORE_SOURCE: . - COV_CORE_CONFIG: .coveragerc - COV_CORE_DATAFILE: .coverage.eager + uv run coverage erase + uv run coverage run -m pytest -n auto --verbose + uv run coverage combine + uv run coverage xml - uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 79c289585f..ac1e731746 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,11 +55,19 @@ be stated twice, the file listed above is the one that governs. tmux >= 3.2a is the compatibility floor (see `tests.yml`'s build matrix). `Server.sessions`, `Server.clients`, and `Server.attached_sessions` return an empty `QueryList` rather than -raising when the underlying tmux list command fails for any reason — -list-shaped accessors are lenient by default; `Server.is_alive()` and -`Server.raise_if_dead()` are the explicit, loud-failure primitives. See -`src/libtmux/AGENTS.md` for the full contract and this package's -logging conventions. +raising when the underlying tmux list invocation fails for any reason. +This does not generalize to every list-shaped accessor: `Server.windows` +and `Server.panes` are lenient only for a not-yet-started daemon or a +missing socket, and `Session.windows`, `Session.panes`, `Window.panes`, +and `Window.search_panes` are not lenient at all — any tmux failure +there raises. `Server.is_alive()` and `Server.raise_if_dead()` are the +explicit, loud-failure primitives; a dead server reading as an empty +live one through the lenient accessors never implies a `Session`/ +`Window` relation obtained beforehand will also read empty rather than +raise. A parse failure (`exc.TmuxRecordParseError`) or a timeout +(`exc.TmuxTimeout`) still propagates through the lenient ones — see +`src/libtmux/AGENTS.md` for the full, precise contract and this +package's logging conventions. ## References diff --git a/CHANGES b/CHANGES index 7a691b0cc9..6ac22ee103 100644 --- a/CHANGES +++ b/CHANGES @@ -45,8 +45,288 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### What's new + +#### Command results and optional timeouts (#758) + +{func}`~libtmux.common.run_command` executes tmux and returns a separate +{class}`~libtmux.common.CommandResult`; the existing +{class}`~libtmux.common.tmux_cmd` facade remains compatible. Optional command +and server timeouts bound calls, including listings, and raise +{exc}`~libtmux.exc.TmuxTimeout` after a bounded cleanup when the deadline +expires, even if a surviving descendant keeps the command's own +stdout/stderr pipes open. {exc}`~libtmux.exc.TmuxTimeout` round-trips through +`pickle` and `copy`, so it survives crossing a `ProcessPoolExecutor` boundary. +{attr}`~libtmux.Server.timeout` has a class-level default of `None`, matching +`tmux_bin` and the other configuration attributes, so an instance built +without going through `__init__` still has a value to read. +{meth}`~libtmux.Server.cmd`'s `timeout` distinguishes an omitted argument +(falls back to `Server.timeout`) from an explicit `None` (runs that one call +unbounded even when the server has a timeout) -- the two used to collapse +onto the same behavior. + +#### Explicitly owned temporary resources (#758) + +{meth}`~libtmux.Server.owned` creates a private server scope, and +{meth}`~libtmux.Server.owned_session` creates a temporary session on an existing +server. Cleanup affects the created resource and reports failures; legacy +handle contexts retain their destructive behavior. See +{doc}`topics/context_managers` for ownership and cleanup rules. The yielded +server's `socket_path` is a `str`, like every other constructor produces, so +it compares equal to the same endpoint addressed by string. + +#### Decoded captured fields (#758) + +Panes and windows expose captured dimensions and activity as numeric and +boolean properties. Panes also expose {attr}`~libtmux.Pane.is_dead`, +{attr}`~libtmux.Pane.left_cells`, and {attr}`~libtmux.Pane.top_cells`, and +sessions expose {attr}`~libtmux.Session.attached_count`. These reads are local; +raw fields and established aliases remain available. + +#### Public local queries (#758) + +{class}`~libtmux.QueryList` is available from the package root. Required +lookups and caller-supplied defaults retain their distinct result types while +filtering the existing collection locally. + +#### A runnable examples/ directory (#758) + +`examples/` holds standalone scripts you run directly -- +`python examples/quickstart.py`, with tmux on `PATH` and no existing session +required. Each owns a private {meth}`~libtmux.Server.owned` daemon, so none +of them touch a session you already have open. The test suite runs every +script as its own subprocess, so an example that stops working fails CI +instead of going stale. See {doc}`topics/examples`. + +### Fixes + +#### `select_layout` accepts only a layout tmux can parse (#758) + +{meth}`~libtmux.Window.select_layout` raises `ValueError` unless the value is a +preset name or a layout string tmux reported -- previously `select_layout("-o")` +silently ran tmux's own *undo* flag, and any other unparseable value reached +tmux. That matters on tmux 3.3 and 3.3a, where an unparseable layout exits the +server and destroys every session on the socket; `--` does not help, since it is +what turns `-o` into such a value. A mirrored preset below tmux 3.5, and a JSON +layout below tmux 3.8, raise {exc}`~libtmux.exc.VersionTooLow` rather than +reaching a tmux that cannot read them. A `--` separator is still passed as +defence in depth. An explicit empty string also raises `ValueError` instead of +silently behaving like an omitted layout (`None`). A unique preset +abbreviation (`"tile"`, `"even-h"`) is now accepted like tmux's own +`layout_set_lookup` accepts it -- it can never reach the crash path above -- +scoped to the presets the live tmux version actually has; an ambiguous one +(`"even-"`) raises `ValueError` naming the candidates instead of claiming tmux +does not know the spelling. + +#### `send_keys`, `capture_pane`, and `enter` raise on a tmux failure (#758) + +{meth}`~libtmux.Pane.send_keys` and {meth}`~libtmux.Pane.capture_pane` never +inspected their own tmux command's result: a killed pane made `send_keys` +return `None` and `capture_pane` return `[]`, indistinguishable from an +empty pane, while {meth}`~libtmux.Pane.refresh` on the same handle already +raised {exc}`~libtmux.exc.TmuxObjectDoesNotExist`. Both, and +{meth}`~libtmux.Pane.enter`, now raise {exc}`~libtmux.exc.LibTmuxException` +carrying tmux's own stderr, matching every other typed method. +`capture_pane(alternate_screen=True)` off the alternate screen now raises +for the same reason -- tmux itself exits 1 (`no alternate screen`); pass +`quiet=True` to keep the previous silent behavior. {meth}`~libtmux.Pane.reset` +and {meth}`~libtmux.Pane.split` join them: `reset()` discarded its command's +result and returned silently on a killed pane; `split()` already raised but +with every field of the pane and its live siblings dumped into the message +(nearly 5 KB) instead of tmux's own one-line `split-window: can't find pane: +...`. + +#### `Server.wait_for` accepts a timeout; `signal=` replaces `set_flag=` (#758) + +{meth}`~libtmux.Server.wait_for` had no way to bound the wait: an +unsignalled channel blocked the caller forever, and `Server.owned()`/ +`Server()` default {attr}`~libtmux.Server.timeout` to `None`. `wait_for` now +accepts `timeout`, forwarded to {meth}`~libtmux.Server.cmd` exactly like +`cmd`'s own override, raising {exc}`~libtmux.exc.TmuxTimeout` on expiry. The +`set_flag` keyword is renamed to `signal` -- tmux's own manual's name for +`wait-for -S` -- with `set_flag` still accepted as a deprecated alias. +A `timeout` of zero or less raises `ValueError` rather than silently +skipping the command -- `subprocess.Popen.communicate(timeout=0)` never +gives the freshly spawned tmux process a chance to respond, so it always +reads as expired, which previously dropped a `signal=True` call with no +indication anything had gone wrong. `wait_for`'s `lock` now documents a +tmux limitation this bound made reachable: a lock wait that times out does +not give the lock back, so every later `wait_for(channel, lock=True)` on +that channel also times out -- use a fresh channel name after a timed-out +lock wait. + +#### `examples/command_results.py` probes an isolated socket (#758) + +The stderr-probe called {func}`~libtmux.common.run_command` with no socket +selector, so it silently fell through to the reader's own default tmux +socket if one happened to be running. It named a socket that cannot +already exist instead, so the probe's outcome no longer depends on the +reader's own tmux state. + +#### `Server.owned` traps `SIGTERM`/`SIGHUP` so cleanup still runs (#758) + +Cleanup lived only in {meth}`~libtmux.Server.owned`'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. `owned()` now traps both, only where nothing already +handles or ignores them and only on the main thread, and runs its cleanup +directly from the handler rather than by raising an exception for the +block to unwind through -- the earlier `SystemExit`-based approach let a +broad `except` anywhere inside the block catch it before cleanup ever ran, +leaking the daemon and letting the process keep running well past its +SIGTERM. The handler now restores the signal's default disposition and +re-raises it against the process once its own cleanup is done, so the +process dies by the signal (a parent sees a signal exit, not exit code +`143`/`129`) and no exception ever reaches the block's own code at all. +Only this endpoint's cleanup runs on this path -- anything else the block +would have unwound through does not get a chance to, same as if the signal +had never been trapped; a caller that wants its own graceful shutdown +installs its own handler before entering the scope. + +#### Listings preserve paths containing newlines (#758) + +Session, window and pane listings remain usable when a pane's directory +contains a newline. The captured path retains its original value. +{meth}`~libtmux.Server.new_session` parses its own ``-P -F`` reply the same +way, so a newline in ``start_directory`` no longer raises `ValueError` before +the `Session` is built. + +#### `Server.sessions`/`Server.clients` agree with the other listings on parse failures (#758) + +A malformed record now raises the new {exc}`~libtmux.exc.TmuxRecordParseError` +instead of the generic {exc}`~libtmux.exc.LibTmuxException`, and +{attr}`~libtmux.Server.sessions` and {attr}`~libtmux.Server.clients` let it +propagate rather than folding it into their empty-by-default contract, which +covers an unreachable server, not a reply libtmux could not parse. This +matches {attr}`~libtmux.Server.windows` and {attr}`~libtmux.Server.panes`, +which already raised it. + +#### `refresh()` clears a field that became empty (#758) + +{meth}`~libtmux.Pane.refresh`, and the same method on +{class}`~libtmux.Window`, {class}`~libtmux.Session`, and +{class}`~libtmux.Client`, now reports a field tmux currently reports empty as +empty. The row parser drops empty values, and `refresh()` used to `setattr` +only the keys present in that filtered row, so a field that went from set to +empty -- a pane title cleared with `select-pane -T ''`, or 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. + +#### Liveness checks honor `Server.timeout` (#758) + +{meth}`~libtmux.Server.is_alive` re-raises {exc}`~libtmux.exc.TmuxTimeout` +instead of reporting a wedged server as dead, and +{meth}`~libtmux.Server.raise_if_dead` now runs through +{meth}`~libtmux.Server.cmd`, so it honors {attr}`~libtmux.Server.timeout` +instead of blocking indefinitely. `Server.__exit__` still attempts +{meth}`~libtmux.Server.kill` when a timeout leaves liveness unknown, rather +than skipping it and leaking the daemon. + +#### `Server.owned_session` no longer leaks on a malformed session (#758) + +Building the created session's identity guard now happens inside a guarded +block that kills the session on failure, instead of in a gap before cleanup +was armed. The guard's checks no longer use bare `assert`, which +`python -O` strips. + +#### Faster cleanup for a stopped control-mode client (#758) + +{class}`~libtmux._internal.control_mode.ControlMode` sends `SIGCONT` before +waiting on a terminated client. A client stopped (e.g. `SIGSTOP`, a debugger) +cannot process `SIGTERM` until resumed, so cleanup previously ran out its +full 5-second wait before falling back to `SIGKILL` every time. + +#### Expanded tmux hook decoding (#758) + +Reading hooks recognizes tmux's additional pane, window, client and command +events, preserving typed access to the complete hook table. + +#### `Server.server_access` orders flags before the user (#758) + +{meth}`~libtmux.Server.server_access` emitted `-a -r`, which tmux's +argument parser reads as two positional arguments once it reaches the bare +username, rejecting the call as "too many arguments" instead of applying +`-r`. Flags now precede the trailing positional user, matching +`server-access`'s own `[-adlrw] [user]` usage. + ### Documentation +#### README's first `.cmd()` snippet runs outside the doctest suite (#758) + +"Run any tmux command" called a bare `Server(socket_name=...)`, which only +resolves during this project's own doctest run -- `conftest.py` rebinds +`Server` there to a test factory. Copied into a fresh interpreter, 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. + +#### Layout save/restore states its pane-identity caveat (#758) + +{meth}`~libtmux.Window.select_layout`'s docstring states plainly that +feeding a saved {attr}`~libtmux.Window.window_layout` back in restores the +shape exactly on every supported tmux version, but *which pane lands in +which cell* is only guaranteed on tmux 3.8+, where every libtmux reader +(there is no public control-mode client) receives a JSON layout carrying +each pane's id. Before 3.8, restoring the classic layout string can +rotate which pane occupies which position even though the resulting +arrangement is identical -- confirmed against raw tmux on 3.2a, 3.7c, and +tmux's git `master`, and now proven for the 3.8+ guarantee by a dedicated +test. + +#### `Pane.is_dead` shows its local-read contract, not just states it (#758) + +`is_dead` already read a captured snapshot -- documented in one line as +"reads locally" -- but a consumer polling it to learn a command finished +got `False` forever regardless: 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, since tmux destroys the pane outright and +{meth}`~libtmux.Pane.refresh` raises +{exc}`~libtmux.exc.TmuxObjectDoesNotExist` rather than reporting +`is_dead=True`. The docstring's `Examples` now show all three cases. + +#### Which list-returning relations are lenient, and how much (#758) + +{attr}`~libtmux.Server.sessions`, {attr}`~libtmux.Server.clients`, and +`Window.linked_sessions` swallow any tmux failure into an empty list. +{attr}`~libtmux.Server.windows` and {attr}`~libtmux.Server.panes` are +lenient only for a not-yet-started daemon or a missing socket, propagating +everything else. `Session.windows`, `Session.panes`, `Window.panes`, and +`Window.search_panes` are not lenient at all -- any tmux failure there +propagates. `src/libtmux/AGENTS.md`'s "List-returning accessors" section +and every affected docstring now state this precisely: seeing +`Server.sessions == []` on a dead server never implies a `Session`/`Window` +relation obtained beforehand will also read empty rather than raise. + +#### Reliable automation examples (#758) + +The {doc}`automation examples ` distinguish +command output from echoed input and verify running and completed states. +Repeated operations use fresh markers, and retries and task queues stop +when a preceding command times out. + +#### Popup and floating-pane examples (#758) + +{meth}`~libtmux.Pane.display_popup` documents the terminal client required to +run popup commands. The {doc}`floating-pane examples ` +distinguish pane content from its borders. + +#### `ControlMode` stays internal, with a documented alternative (#758) + +`libtmux._internal.control_mode.ControlMode` decodes none of tmux's +control-mode protocol -- it exists so this project's own tests have a real +attached client, not to give callers a parsed event stream. It stays +internal rather than being promoted with that gap undocumented. +{doc}`topics/public-vs-internal` now states this directly and names the +alternatives: polling (`Session.windows`, `Window.panes`, wrapped in +{func}`~libtmux.test.retry.retry_until`), {meth}`~libtmux.Pane.pipe` for raw +output streaming, and {mod}`~libtmux.hooks` for server-side events. Several +public docstrings (`Server.display_menu`, `Server.show_messages`, +`Server.display_message`) pointed a reader at the internal class by name; +they now describe attaching any real client instead. + #### Cleaner `from_env` examples (#719) The rendered examples for {meth}`Pane.from_env() ` and @@ -59,6 +339,39 @@ it. ### Development +#### `pytest benchmarks/` errors instead of silently collecting nothing (#758) + +Pointing plain `pytest` at `benchmarks/` collected 0 items and exited 0: +pytest's default `python_files` (`test_*.py`) never matches this +directory's `bench_*.py` files, which read as "ran fine, nothing to +benchmark" rather than "wrong invocation." The root `conftest.py` now +raises `pytest.UsageError`, naming `just bench`, whenever an invocation +that names `benchmarks/` directly collects zero items; a broader scan that +merely walks through the directory on its way elsewhere is unaffected. + +#### The `master`-tmux CI job now gates (#758) + +The `master` matrix entry's `Test with pytest` step no longer carries +`continue-on-error`. It already builds tmux from git `master` and runs the +full suite on every push and pull request; letting that step fail silently +gave up the one lane built to catch a behavior change before its release, +while contributing nothing that `addopts`' `--reruns=2` doesn't already +cover for genuine flakiness. Tmux's own build failing still fails a separate, +earlier step regardless of this change. Verified green across this branch's +last several pushes on tmux's real git `master` before flipping. + +#### A benchmark suite (#758) + +`benchmarks/` measures command dispatch (`Server.cmd`), listing +(`Server.sessions`/`.windows`/`.panes`), snapshot capture +(`Pane.capture_pane`), and format decoding (`neo.parse_output`, +`neo._split_records`), via [pytest-benchmark]. Run with `just bench`; it +is a separate tier from the gates above, not part of them, and is not in +`testpaths`. See {doc}`Contributing ` for where +this fits alongside the gates. + +[pytest-benchmark]: https://pytest-benchmark.readthedocs.io/ + #### CI actions updated to current majors Workflow actions moved to their current major releases: `actions/checkout` v7, diff --git a/README.md b/README.md index 4efd2ed963..c5a9c91496 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,10 @@ Server(socket_path=/tmp/tmux-.../default) **Tip:** You can also use [tmuxp]'s [`tmuxp shell`] to drop straight into your current tmux server / session / window / pane. +Prefer a script over a REPL? [`examples/`][examples] holds standalone, +runnable programs — `python examples/quickstart.py` and no existing session +required. + [ptpython]: https://github.com/prompt-toolkit/ptpython [ipython]: https://ipython.org/ [`tmuxp shell`]: https://tmuxp.git-pull.com/cli/shell/ @@ -125,8 +129,9 @@ current tmux server / session / window / pane. Every object has a `.cmd()` escape hatch that honors socket name and path: ```python ->>> server = Server(socket_name='libtmux_doctest') ->>> server.cmd('display-message', 'hello world') +>>> from libtmux.server import Server +>>> with Server(socket_name='libtmux_doctest') as server: +... server.cmd('display-message', 'hello world') ``` @@ -314,7 +319,8 @@ def test_my_tmux_tool(session): [Workspace Setup](https://libtmux.git-pull.com/topics/workspace_setup/) · [Automation Patterns](https://libtmux.git-pull.com/topics/automation_patterns/) · [Context Managers](https://libtmux.git-pull.com/topics/context_managers/) · -[Options & Hooks](https://libtmux.git-pull.com/topics/options_and_hooks/) +[Options & Hooks](https://libtmux.git-pull.com/topics/options_and_hooks/) · +[Examples](https://libtmux.git-pull.com/topics/examples/) **Reference:** [Docs][docs] · @@ -350,3 +356,4 @@ Contributions are welcome. Please open an issue or PR if you find a bug or want [tao]: https://leanpub.com/the-tao-of-tmux [tmuxp]: https://tmuxp.git-pull.com [tmux]: https://github.com/tmux/tmux +[examples]: https://github.com/tmux-python/libtmux/tree/master/examples diff --git a/benchmarks/bench_capture.py b/benchmarks/bench_capture.py new file mode 100644 index 0000000000..987f4e6553 --- /dev/null +++ b/benchmarks/bench_capture.py @@ -0,0 +1,51 @@ +"""Benchmark: Pane.capture_pane() snapshot capture. + +Run with:: + + $ just bench + +Equivalent to:: + + $ uv run pytest benchmarks/ -o python_files='bench_*.py' --benchmark-only + +Not part of ``pytest``'s default run -- see bench_dispatch.py. +""" + +from __future__ import annotations + +import pytest +import pytest_benchmark.fixture + +from libtmux.pane import Pane +from libtmux.session import Session +from libtmux.test.retry import retry_until + +_SCROLLBACK_LINES = 200 + + +@pytest.fixture +def pane_with_scrollback(session: Session) -> Pane: + """Return a pane with a full screen of numbered scrollback lines.""" + window = session.new_window(window_name="bench-capture", window_shell="sh") + pane = window.active_pane + assert pane is not None + fill_command = ( + f"i=0; while [ $i -lt {_SCROLLBACK_LINES} ]; do " + "echo line-$i; i=$((i+1)); done; echo capture-bench-done" + ) + pane.send_keys(fill_command) + retry_until( + lambda: any( + line.rstrip(" ") == "capture-bench-done" for line in pane.capture_pane() + ), + raises=True, + ) + return pane + + +def test_bench_capture_pane( + benchmark: pytest_benchmark.fixture.BenchmarkFixture, + pane_with_scrollback: Pane, +) -> None: + """capture_pane(): one list-panes read plus the visible screen text.""" + benchmark(pane_with_scrollback.capture_pane) diff --git a/benchmarks/bench_dispatch.py b/benchmarks/bench_dispatch.py new file mode 100644 index 0000000000..5274b89830 --- /dev/null +++ b/benchmarks/bench_dispatch.py @@ -0,0 +1,29 @@ +"""Benchmark: Server.cmd() dispatch, the primitive every wrapper method uses. + +Run with:: + + $ just bench + +Equivalent to:: + + $ uv run pytest benchmarks/ -o python_files='bench_*.py' --benchmark-only + +Not part of ``pytest``'s default run -- ``benchmarks/`` is not in +``testpaths``, and performance work is a separate tier from the test-loop +budgets in CONTRIBUTING.md, not part of them. +""" + +from __future__ import annotations + +import pytest_benchmark.fixture + +from libtmux.session import Session + + +def test_bench_command_dispatch( + benchmark: pytest_benchmark.fixture.BenchmarkFixture, + session: Session, +) -> None: + """One round trip through Server.cmd(): fork, exec, read, parse exit.""" + server = session.server + benchmark(server.cmd, "display-message", "-p", "#{session_name}") diff --git a/benchmarks/bench_format_decode.py b/benchmarks/bench_format_decode.py new file mode 100644 index 0000000000..e94d1b56a8 --- /dev/null +++ b/benchmarks/bench_format_decode.py @@ -0,0 +1,52 @@ +"""Benchmark: decoding a tmux ``-F`` reply into typed records. + +Synthetic input, no tmux process involved -- this isolates decode cost +(splitting on ``FORMAT_SEPARATOR``, zipping into fields, dropping empties) +from the subprocess round trip that bench_listing.py measures instead. + +Run with:: + + $ just bench + +Equivalent to:: + + $ uv run pytest benchmarks/ -o python_files='bench_*.py' --benchmark-only + +Not part of ``pytest``'s default run -- see bench_dispatch.py. +""" + +from __future__ import annotations + +import pytest_benchmark.fixture + +from libtmux.formats import FORMAT_SEPARATOR +from libtmux.neo import _split_records, get_output_format, parse_output + +_RECORD_COUNT = 64 + + +def _synthetic_pane_blob() -> tuple[str, int]: + """Build a synthetic multi-record ``list-panes -F`` reply.""" + fields, _ = get_output_format("list-panes", "3.6a") + record = FORMAT_SEPARATOR.join(f"{name}-value" for name in fields) + ( + FORMAT_SEPARATOR + ) + return "\n".join([record] * _RECORD_COUNT), len(fields) + + +def test_bench_split_records( + benchmark: pytest_benchmark.fixture.BenchmarkFixture, +) -> None: + """_split_records(): regroup 64 records' worth of raw stdout lines.""" + blob, field_count = _synthetic_pane_blob() + stdout = blob.split("\n") + benchmark(_split_records, stdout, field_count) + + +def test_bench_parse_output( + benchmark: pytest_benchmark.fixture.BenchmarkFixture, +) -> None: + """parse_output(): decode one already-split record into a dict.""" + blob, _ = _synthetic_pane_blob() + one_record = blob.split("\n", 1)[0] + benchmark(parse_output, one_record, "list-panes", "3.6a") diff --git a/benchmarks/bench_listing.py b/benchmarks/bench_listing.py new file mode 100644 index 0000000000..836d284069 --- /dev/null +++ b/benchmarks/bench_listing.py @@ -0,0 +1,59 @@ +"""Benchmark: Server.sessions / .windows / .panes listing. + +Run with:: + + $ just bench + +Equivalent to:: + + $ uv run pytest benchmarks/ -o python_files='bench_*.py' --benchmark-only + +Not part of ``pytest``'s default run -- see bench_dispatch.py. +""" + +from __future__ import annotations + +import pytest +import pytest_benchmark.fixture + +from libtmux.session import Session + +_WINDOW_COUNT = 8 +_PANES_PER_WINDOW = 4 + + +@pytest.fixture +def populated_session(session: Session) -> Session: + """Return a session with several windows, each split into several panes.""" + for i in range(_WINDOW_COUNT): + window = session.new_window(window_name=f"bench-{i}", window_shell="sh") + for _ in range(_PANES_PER_WINDOW - 1): + window.split(attach=False) + return session + + +def test_bench_server_sessions( + benchmark: pytest_benchmark.fixture.BenchmarkFixture, + populated_session: Session, +) -> None: + """Server.sessions: one list-sessions call, decoded into objects.""" + server = populated_session.server + benchmark(lambda: server.sessions) + + +def test_bench_server_windows( + benchmark: pytest_benchmark.fixture.BenchmarkFixture, + populated_session: Session, +) -> None: + """Server.windows: one list-windows call across every session.""" + server = populated_session.server + benchmark(lambda: server.windows) + + +def test_bench_server_panes( + benchmark: pytest_benchmark.fixture.BenchmarkFixture, + populated_session: Session, +) -> None: + """Server.panes: one list-panes call across every window.""" + server = populated_session.server + benchmark(lambda: server.panes) diff --git a/conftest.py b/conftest.py index 88a2656d29..dcd0f349d2 100644 --- a/conftest.py +++ b/conftest.py @@ -23,14 +23,56 @@ from libtmux.pytest_plugin import USING_ZSH from libtmux.server import Server from libtmux.session import Session +from libtmux.test.retry import retry_until from libtmux.window import Window if t.TYPE_CHECKING: import pathlib + from collections.abc import Sequence pytest_plugins = ["pytester"] +def _requested_benchmarks_directly(args: Sequence[str]) -> bool: + """Return True if a positional argument names ``benchmarks`` directly. + + Guards against firing on a broader scan (e.g. ``pytest .``) that + merely walks through ``benchmarks/`` on its way elsewhere -- only an + invocation that explicitly names that bare path should raise. A path + to one file inside it (``benchmarks/bench_capture.py::test_x``) + already collected its item and needs no rescue. + """ + return any(arg.rstrip("/") == "benchmarks" for arg in args) + + +def pytest_collection_modifyitems( + config: pytest.Config, + items: list[pytest.Item], +) -> None: + """Replace a silent zero-item ``benchmarks/`` run with a clear error. + + ``benchmarks/`` is deliberately outside ``testpaths`` and its files + are named ``bench_*.py``, not ``test_*.py`` (see CONTRIBUTING.md's + "Benchmarks" section). Pointing plain ``pytest`` at it directly + (``uv run pytest benchmarks/``) collects nothing and exits 0 -- + pytest's default ``python_files`` glob never matches ``bench_*.py`` + -- which reads as "ran fine, nothing to benchmark" rather than + "wrong invocation" (PY-11). + """ + if items: + return + if not _requested_benchmarks_directly(config.args): + return + msg = ( + "benchmarks/ collected 0 items: pytest's default python_files " + "('test_*.py') does not match this directory's 'bench_*.py' " + "files. Run `just bench`, or " + "`uv run pytest benchmarks/ -o python_files='bench_*.py' " + "--benchmark-only` directly." + ) + raise pytest.UsageError(msg) + + @pytest.fixture(autouse=True) def add_doctest_fixtures( request: pytest.FixtureRequest, @@ -52,6 +94,7 @@ def add_doctest_fixtures( doctest_namespace["pane"] = session.active_pane doctest_namespace["request"] = request doctest_namespace["ControlMode"] = ControlMode + doctest_namespace["retry_until"] = retry_until doctest_namespace["control_mode"] = functools.partial( ControlMode, server=session.server, diff --git a/docs/api/index.md b/docs/api/index.md index 23cd9043b1..b83616b7e2 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -81,6 +81,10 @@ Attached terminal. Read read-only state, theme, termtype. ## Supporting Modules +{class}`~libtmux.QueryList` is the ordinary list subclass returned by live +listings. Its {doc}`public query API ` filters those returned +values locally and provides required or defaulted lookups. + ::::{grid} 1 2 3 3 :gutter: 2 2 3 3 @@ -173,6 +177,7 @@ Window Pane Client Common +QueryList Neo Options Hooks diff --git a/docs/api/libtmux.common.md b/docs/api/libtmux.common.md index 8d15a0520c..03b34b94c1 100644 --- a/docs/api/libtmux.common.md +++ b/docs/api/libtmux.common.md @@ -1,5 +1,22 @@ # Utilities +{func}`libtmux.common.run_command` executes tmux and returns a separate +{class}`libtmux.common.CommandResult`. Constructing a result performs no I/O. +Completed nonzero exits remain result data; transport and timeout failures raise. + +```python +>>> from libtmux.common import CommandResult, run_command +>>> result = run_command("-V") +>>> isinstance(result, CommandResult) +True +>>> result.returncode +0 +``` + +The existing {class}`libtmux.common.tmux_cmd` constructor delegates to this +runner. Contextual `server.cmd()` calls retain their existing return type, +attributes and output conventions. + ```{eval-rst} .. automodule:: libtmux.common :members: diff --git a/docs/api/libtmux.pane.md b/docs/api/libtmux.pane.md index 383b4286f3..c47a150afd 100644 --- a/docs/api/libtmux.pane.md +++ b/docs/api/libtmux.pane.md @@ -9,6 +9,22 @@ [pseudoterminal]: https://en.wikipedia.org/wiki/Pseudoterminal [pty(4)]: https://www.freebsd.org/cgi/man.cgi?query=pty&sektion=4 +`width_cells` and `height_cells` return captured dimensions as `int | None`; +`is_active` and `is_dead` return captured flags as `bool | None`. These reads +perform no I/O and preserve `None` when a field was unavailable. The raw +`pane_*` fields and existing string-valued `width` and `height` aliases remain +available. Invalid manually assigned numeric text raises `ValueError` when +decoded. + +```python +>>> isinstance(pane.width_cells, int) +True +>>> pane.width == pane.pane_width +True +>>> pane.is_dead +False +``` + ```{eval-rst} .. autoclass:: libtmux.Pane :members: diff --git a/docs/api/libtmux.query.md b/docs/api/libtmux.query.md new file mode 100644 index 0000000000..bce86761d6 --- /dev/null +++ b/docs/api/libtmux.query.md @@ -0,0 +1,28 @@ +# QueryList + +Import {class}`~libtmux.QueryList` from the package root. It is the same list +subclass returned by session, window and pane listings. Iteration, slicing and +comprehensions keep their existing Python list behavior. + +```python +>>> from libtmux import QueryList +>>> values = QueryList([1, 2, 3]) +>>> values.filter(lambda value: value > 1) +[2, 3] +>>> values.get(2) +2 +>>> values.get(9, default="missing") +'missing' +``` + +`get()` without a default returns the element type or raises +{class}`~libtmux.exc.ObjectDoesNotExist`. Supplying a default adds that default's +type to the return type. Both forms raise +{class}`~libtmux.exc.MultipleObjectsReturned` for an ambiguous match. Filtering +and lookup perform no I/O; obtaining `server.sessions` still performs a live +read with classic libtmux's listing behavior. + +```{eval-rst} +.. autoclass:: libtmux.QueryList + :members: +``` diff --git a/docs/api/libtmux.session.md b/docs/api/libtmux.session.md index 008d64df9f..a7d0ef5ded 100644 --- a/docs/api/libtmux.session.md +++ b/docs/api/libtmux.session.md @@ -6,6 +6,11 @@ - Contain {ref}`Windows` (which contain {ref}`Panes`) - Identified by `$`, e.g. `$313` +`attached_count` returns the captured number of attached clients as `int | None` +without performing I/O. `None` means the field was unavailable. The raw +`session_attached` string remains available; invalid manually assigned numeric +text raises `ValueError` when decoded. + ```{eval-rst} .. autoclass:: libtmux.Session :members: diff --git a/docs/api/libtmux.window.md b/docs/api/libtmux.window.md index 384879ae62..95242d5cca 100644 --- a/docs/api/libtmux.window.md +++ b/docs/api/libtmux.window.md @@ -10,6 +10,13 @@ :no-index: ``` +`width_cells` and `height_cells` return captured dimensions as `int | None`. +`is_active` returns the captured active flag within the session as `bool | None`. +These properties perform no I/O; `None` means the field was unavailable. +The raw `window_*` fields and existing string-valued dimension aliases remain +unchanged. Invalid manually assigned numeric text raises `ValueError` when +decoded. + ```{eval-rst} .. autoclass:: Window :members: diff --git a/docs/internals/api/libtmux._internal.query_list.md b/docs/internals/api/libtmux._internal.query_list.md index 3e195139cc..b50c9dd9a2 100644 --- a/docs/internals/api/libtmux._internal.query_list.md +++ b/docs/internals/api/libtmux._internal.query_list.md @@ -1,7 +1,8 @@ # List querying The {mod}`libtmux._internal.query_list` module contains the private collection -filtering implementation behind public list accessors. +filtering implementation behind public list accessors. Consumers use the +{doc}`public QueryList import <../../api/libtmux.query>`. ```{eval-rst} .. automodule:: libtmux._internal.query_list diff --git a/docs/project/public-api.md b/docs/project/public-api.md index ba7eae89a4..76f12f9ea4 100644 --- a/docs/project/public-api.md +++ b/docs/project/public-api.md @@ -13,6 +13,7 @@ This includes: | {class}`~libtmux.Session` | `from libtmux.session import Session` | | {class}`~libtmux.Window` | `from libtmux.window import Window` | | {class}`~libtmux.Pane` | `from libtmux.pane import Pane` | +| {class}`~libtmux.QueryList` | `from libtmux import QueryList` | | Common | `from libtmux.common import ...` | | Neo | `from libtmux.neo import ...` | | Options | `from libtmux.options import ...` | @@ -32,6 +33,10 @@ This includes: Modules under `libtmux._internal` and `libtmux._vendor` are **not public**. They may change or be removed without notice between any release. +`QueryList` is public through its package-root import even though its +implementation lives in `_internal`. Use the public import rather than the +implementation module. + Do not import from: - `libtmux._internal.*` - `libtmux._vendor.*` diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index ba96411085..d51da5c601 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -47,6 +47,10 @@ doctests against a live tmux server; in your own scripts you create them yoursel {meth}`~libtmux.Server.new_session`). Each example builds its own window or pane and tears it down at the end, so the snippets stand alone and don't depend on each other. +The sample windows run a plain POSIX shell so user shell initialization does not +delay their commands. Match complete output lines, and choose fresh markers when +reusing a pane so an earlier command's output cannot satisfy a later wait. + ## Process control ### Starting long-running processes @@ -58,13 +62,13 @@ going in the pane. The pane object stays your handle on that running work. ```python >>> import time ->>> proc_window = session.new_window(window_name='process', attach=False) +>>> proc_window = session.new_window(window_name='process', attach=False, window_shell='sh') >>> proc_pane = proc_window.active_pane >>> # Start a background process >>> proc_pane.send_keys('sleep 2 && echo "Process complete"') ->>> # Process is running +>>> # The window remains available to the caller. >>> time.sleep(0.1) >>> proc_window.window_name 'process' @@ -78,31 +82,50 @@ going in the pane. The pane object stays your handle on that running work. Because {meth}`send_keys() ` doesn't wait, you find out whether a command is still running the same way a person would: by reading what's on screen. Capture the pane and look for a marker your command prints -when it reaches a known state. +when it reaches a known state. Match whole lines and check the completion +marker first: a start marker remains in the scrollback after a command ends. +Joined captures can include right-padding spaces, including on tmux 3.2a. +These markers have no trailing spaces, so comparisons remove ASCII spaces +from each line's right edge before checking equality. ```python >>> import time ->>> status_window = session.new_window(window_name='status-check', attach=False) +>>> status_window = session.new_window(window_name='status-check', attach=False, window_shell='sh') >>> status_pane = status_window.active_pane ->>> def is_process_running(pane, marker='RUNNING'): -... """Check if a marker indicates process is still running.""" -... output = pane.capture_pane() -... return marker in '\\n'.join(output) +>>> def is_process_running(pane, marker='RUNNING', completed='DONE'): +... """Check whether output records a start without completion.""" +... lines = [line.rstrip(' ') for line in pane.capture_pane(join_wrapped=True)] +... return completed not in lines and marker in lines ->>> # Start and mark a process ->>> status_pane.send_keys('echo "RUNNING"; sleep 0.3; echo "DONE"') ->>> time.sleep(0.1) +>>> is_process_running(status_pane) +False + +>>> # Wait for input so the running state lasts until we release it. +>>> status_pane.send_keys(r'printf "\nRUNNING\n"; read response; printf "\nDONE\n"') ->>> # Check while running ->>> 'RUNNING' in '\\n'.join(status_pane.capture_pane()) +>>> deadline = time.monotonic() + 2.0 +>>> while time.monotonic() < deadline: +... if any(line.rstrip(' ') == 'RUNNING' for line in status_pane.capture_pane(join_wrapped=True)): +... break +... time.sleep(0.05) +>>> is_process_running(status_pane) True +>>> # Enter releases read; the command can now print its completion marker. +>>> _ = status_pane.enter() + >>> # Wait for completion ->>> time.sleep(0.5) ->>> 'DONE' in '\\n'.join(status_pane.capture_pane()) +>>> deadline = time.monotonic() + 2.0 +>>> while time.monotonic() < deadline: +... if any(line.rstrip(' ') == 'DONE' for line in status_pane.capture_pane(join_wrapped=True)): +... break +... time.sleep(0.05) +>>> any(line.rstrip(' ') == 'DONE' for line in status_pane.capture_pane(join_wrapped=True)) True +>>> is_process_running(status_pane) +False >>> # Clean up >>> status_window.kill() @@ -112,29 +135,29 @@ True ### Waiting for specific output -The workhorse of terminal automation is "run something, then block until a string -shows up." You wrap {meth}`~libtmux.Pane.capture_pane` in a loop with a timeout, so a -command that never finishes can't hang your script forever. The `poll_interval` is -the latency/work trade in one knob: poll faster to react sooner, slower to spare tmux -the round-trips. +Wait for a whole output line so an echoed shell command cannot satisfy the +condition before it runs. A timeout bounds the wait. The `poll_interval` +controls how often the loop calls {meth}`~libtmux.Pane.capture_pane`. ```python >>> import time ->>> monitor_window = session.new_window(window_name='monitor', attach=False) +>>> monitor_window = session.new_window(window_name='monitor', attach=False, window_shell='sh') >>> monitor_pane = monitor_window.active_pane >>> def wait_for_output(pane, text, timeout=5.0, poll_interval=0.1): -... """Wait for specific text to appear in pane output.""" -... start = time.time() -... while time.time() - start < timeout: -... output = '\\n'.join(pane.capture_pane()) -... if text in output: +... """Wait for an exact line of pane output.""" +... deadline = time.monotonic() + timeout +... while time.monotonic() < deadline: +... if any(line.rstrip(' ') == text for line in pane.capture_pane(join_wrapped=True)): ... return True ... time.sleep(poll_interval) ... return False ->>> monitor_pane.send_keys('sleep 0.2; echo "READY"') +>>> monitor_pane.send_keys(r'printf "\nREADY\n"', enter=False) +>>> wait_for_output(monitor_pane, 'READY', timeout=0.1) +False +>>> _ = monitor_pane.enter() >>> wait_for_output(monitor_pane, 'READY', timeout=2.0) True @@ -151,68 +174,82 @@ early instead of timing out on a command that already crashed. ```python >>> import time ->>> error_window = session.new_window(window_name='error-check', attach=False) +>>> error_window = session.new_window(window_name='error-check', attach=False, window_shell='sh') >>> error_pane = error_window.active_pane >>> def check_for_errors(pane, patterns=None): ... """Check pane output for error patterns.""" ... if patterns is None: ... patterns = ['Error:', 'error:', 'ERROR', 'FAILED', 'Exception'] -... output = '\\n'.join(pane.capture_pane()) +... lines = pane.capture_pane(join_wrapped=True) ... for pattern in patterns: -... if pattern in output: +... if any(line.startswith(pattern) for line in lines): ... return pattern ... return None >>> # Test with successful output ->>> error_pane.send_keys('echo "Success!"') ->>> time.sleep(0.1) +>>> error_pane.send_keys(r'printf "\nSuccess!\n"') +>>> deadline = time.monotonic() + 2.0 +>>> while time.monotonic() < deadline: +... if any(line.rstrip(' ') == 'Success!' for line in error_pane.capture_pane(join_wrapped=True)): +... break +... time.sleep(0.05) +>>> any(line.rstrip(' ') == 'Success!' for line in error_pane.capture_pane(join_wrapped=True)) +True >>> check_for_errors(error_pane) is None True +>>> # An error in the typed command is not output yet. +>>> error_pane.send_keys(r'printf "\nError: unavailable\n"', enter=False) +>>> check_for_errors(error_pane) is None +True +>>> _ = error_pane.enter() +>>> deadline = time.monotonic() + 2.0 +>>> while time.monotonic() < deadline and check_for_errors(error_pane) is None: +... time.sleep(0.05) +>>> check_for_errors(error_pane) +'Error:' + >>> # Clean up >>> error_window.kill() ``` ### Capturing output between markers -Sometimes you don't want the whole scrollback — you want just the lines a command -produced. Bracket the interesting output with a marker you control, then return -everything that follows it. This is how you pull a command's result out of a shared -pane without dragging along the prompt and prior history. +Bracket a command's output with distinct start and end markers. Match whole +lines so echoed command text cannot satisfy the wait. The end marker confirms +that the command has finished writing its output. The helper preserves payload +lines, including their trailing spaces; the example removes those spaces only +when displaying its result. ```python >>> import time ->>> capture_window = session.new_window(window_name='capture', attach=False) +>>> capture_window = session.new_window(window_name='capture', attach=False, window_shell='sh') >>> capture_pane = capture_window.active_pane ->>> def capture_after_marker(pane, marker, timeout=5.0): -... """Capture output after a marker appears.""" -... start_time = time.time() -... while time.time() - start_time < timeout: -... lines = pane.capture_pane() -... output = '\\n'.join(lines) -... if marker in output: -... # Return all lines after the marker -... found = False -... result = [] -... for line in lines: -... if marker in line: -... found = True -... continue -... if found: -... result.append(line) -... return result -... time.sleep(0.1) +>>> def capture_between_markers(pane, start_marker, end_marker, timeout=5.0): +... """Capture complete output between two exact marker lines.""" +... deadline = time.monotonic() + timeout +... while time.monotonic() < deadline: +... lines = pane.capture_pane(join_wrapped=True) +... markers = [line.rstrip(' ') for line in lines] +... try: +... start = markers.index(start_marker) +... end = markers.index(end_marker, start + 1) +... except ValueError: +... time.sleep(0.05) +... continue +... return lines[start + 1:end] ... return None >>> # Test marker capture ->>> capture_pane.send_keys('echo "MARKER"; echo "captured data"') ->>> time.sleep(0.3) ->>> result = capture_after_marker(capture_pane, 'MARKER', timeout=2.0) ->>> any('captured' in line for line in (result or [])) -True +>>> capture_pane.send_keys( +... r'printf "\n%s\n%s\n%s\n" "BEGIN" "captured data" "END"' +... ) +>>> captured = capture_between_markers(capture_pane, 'BEGIN', 'END', timeout=2.0) +>>> [line.rstrip(' ') for line in captured] +['captured data'] >>> # Clean up >>> capture_window.kill() @@ -232,29 +269,46 @@ concurrently; you gather their results afterward by capturing every pane. >>> import time >>> from libtmux.constants import PaneDirection ->>> parallel_window = session.new_window(window_name='parallel', attach=False) +>>> parallel_window = session.new_window(window_name='parallel', attach=False, window_shell='sh') >>> parallel_window.resize(height=40, width=120) # doctest: +ELLIPSIS Window(@... ...) >>> pane1 = parallel_window.active_pane ->>> pane2 = pane1.split(direction=PaneDirection.Right) ->>> pane3 = pane1.split(direction=PaneDirection.Below) +>>> pane2 = pane1.split(direction=PaneDirection.Right, shell='sh') +>>> pane3 = pane1.split(direction=PaneDirection.Below, shell='sh') >>> # Start tasks in parallel >>> tasks = [ -... (pane1, 'echo "Task 1"; sleep 0.2; echo "DONE1"'), -... (pane2, 'echo "Task 2"; sleep 0.1; echo "DONE2"'), -... (pane3, 'echo "Task 3"; sleep 0.3; echo "DONE3"'), +... (pane1, r'echo "Task 1"; sleep 0.2; printf "\nDONE1\n"', 'DONE1'), +... (pane2, r'echo "Task 2"; sleep 0.1; printf "\nDONE2\n"', 'DONE2'), +... (pane3, r'echo "Task 3"; sleep 0.3; printf "\nDONE3\n"', 'DONE3'), ... ] ->>> for pane, cmd in tasks: -... pane.send_keys(cmd) +>>> for pane, cmd, marker in tasks: +... pane.send_keys(cmd, enter=False) +>>> any( +... any(line.rstrip(' ') == marker for line in p.capture_pane(join_wrapped=True)) +... for p, _, marker in tasks +... ) +False +>>> for pane, _, _ in tasks: +... _ = pane.enter() >>> # Wait for all tasks ->>> time.sleep(0.5) +>>> deadline = time.monotonic() + 2.0 +>>> while time.monotonic() < deadline: +... if all( +... any(line.rstrip(' ') == marker for line in p.capture_pane(join_wrapped=True)) +... for p, _, marker in tasks +... ): +... break +... time.sleep(0.05) >>> # Verify all completed ->>> all('DONE' in '\\n'.join(p.capture_pane()) for p, _ in tasks) +>>> all( +... any(line.rstrip(' ') == marker for line in p.capture_pane(join_wrapped=True)) +... for p, _, marker in tasks +... ) True >>> # Clean up @@ -272,28 +326,34 @@ instead of always waiting for a worst-case timeout. >>> import time >>> from libtmux.constants import PaneDirection ->>> multi_window = session.new_window(window_name='multi-monitor', attach=False) +>>> multi_window = session.new_window(window_name='multi-monitor', attach=False, window_shell='sh') >>> multi_window.resize(height=40, width=120) # doctest: +ELLIPSIS Window(@... ...) >>> panes = [multi_window.active_pane] ->>> panes.append(panes[0].split(direction=PaneDirection.Right)) ->>> panes.append(panes[0].split(direction=PaneDirection.Below)) +>>> panes.append(panes[0].split(direction=PaneDirection.Right, shell='sh')) +>>> panes.append(panes[0].split(direction=PaneDirection.Below, shell='sh')) >>> def wait_all_complete(panes, marker='COMPLETE', timeout=10.0): ... """Wait for all panes to show completion marker.""" -... start = time.time() +... deadline = time.monotonic() + timeout ... remaining = set(range(len(panes))) -... while remaining and time.time() - start < timeout: +... while remaining and time.monotonic() < deadline: ... for i in list(remaining): -... if marker in '\\n'.join(panes[i].capture_pane()): +... if any(line.rstrip(' ') == marker for line in panes[i].capture_pane(join_wrapped=True)): ... remaining.remove(i) -... time.sleep(0.1) +... if remaining: +... time.sleep(0.05) ... return len(remaining) == 0 >>> # Start tasks with different durations >>> for i, pane in enumerate(panes): -... pane.send_keys(f'sleep 0.{i+1}; echo "COMPLETE"') +... pane.send_keys(fr'sleep 0.{i+1}; printf "\nCOMPLETE\n"', enter=False) + +>>> wait_all_complete(panes, 'COMPLETE', timeout=0.1) +False +>>> for pane in panes: +... _ = pane.enter() >>> # Wait for all >>> wait_all_complete(panes, 'COMPLETE', timeout=2.0) @@ -315,8 +375,8 @@ down, but you get a guaranteed-clean slate that never leaks. ```python >>> # Create isolated session for a task ->>> with server.new_session(session_name='temp-work') as temp_session: -... window = temp_session.new_window(window_name='task') +>>> with server.new_session(session_name='temp-work', window_command='sh') as temp_session: +... window = temp_session.new_window(window_name='task', window_shell='sh') ... pane = window.active_pane ... pane.send_keys('echo "Isolated work"') ... # Session exists during work @@ -337,11 +397,15 @@ never outlives its purpose. ```python >>> import time ->>> with session.new_window(window_name='subtask') as sub_window: +>>> with session.new_window(window_name='subtask', window_shell='sh') as sub_window: ... pane = sub_window.active_pane -... pane.send_keys('echo "Subtask running"') -... time.sleep(0.1) -... 'Subtask' in '\\n'.join(pane.capture_pane()) +... pane.send_keys(r'printf "\nSubtask running\n"') +... deadline = time.monotonic() + 2.0 +... while time.monotonic() < deadline: +... if any(line.rstrip(' ') == 'Subtask running' for line in pane.capture_pane(join_wrapped=True)): +... break +... time.sleep(0.05) +... any(line.rstrip(' ') == 'Subtask running' for line in pane.capture_pane(join_wrapped=True)) True >>> # Window cleaned up automatically @@ -356,12 +420,15 @@ True Any command you wait on can hang, so give every wait an upper bound. Pair the command with a completion marker and poll until either the marker shows up or the clock runs out — and when it runs out, raise, so a stuck command surfaces as an error you can -catch instead of a script that quietly stalls. +catch instead of a script that quietly stalls. A timeout stops waiting; it does +not cancel the command. This example kills its temporary window afterward. ```python +>>> import shlex >>> import time +>>> import uuid ->>> timeout_window = session.new_window(window_name='timeout-demo', attach=False) +>>> timeout_window = session.new_window(window_name='timeout-demo', attach=False, window_shell='sh') >>> timeout_pane = timeout_window.active_pane >>> class CommandTimeout(Exception): @@ -370,55 +437,83 @@ catch instead of a script that quietly stalls. >>> def run_with_timeout(pane, command, marker='__DONE__', timeout=5.0): ... """Run command and wait for completion with timeout.""" -... pane.send_keys(f'{command}; echo {marker}') -... start = time.time() -... while time.time() - start < timeout: -... output = '\\n'.join(pane.capture_pane()) -... if marker in output: -... return output -... time.sleep(0.1) +... marker = f'{marker}_{uuid.uuid4().hex}' +... pane.send_keys(fr'{command}; printf "\n%s\n" {shlex.quote(marker)}') +... deadline = time.monotonic() + timeout +... while time.monotonic() < deadline: +... lines = pane.capture_pane(join_wrapped=True) +... if any(line.rstrip(' ') == marker for line in lines): +... return '\n'.join(lines) +... time.sleep(0.05) ... raise CommandTimeout(f'Command timed out after {timeout}s') >>> # Test successful command ->>> result = run_with_timeout(timeout_pane, 'echo "fast"', timeout=2.0) ->>> 'fast' in result +>>> result = run_with_timeout(timeout_pane, r'printf "\nfast\n"', timeout=2.0) +>>> any(line.rstrip(' ') == 'fast' for line in result.splitlines()) True +>>> # This command waits for input; the previous marker must not complete it. +>>> try: +... run_with_timeout(timeout_pane, 'read response', timeout=0.1) +... except CommandTimeout: +... print('timed out') +timed out + >>> # Clean up >>> timeout_window.kill() ``` ### Retry pattern -For flaky work that succeeds on a later attempt, retry until a success marker -appears. Be honest about the cost: each retry runs the command again and waits the -full `delay`, so a slow `delay` times `max_retries` is the worst case you're signing -up for. Tune both for how expensive the command is and how patient you can be. +Retry only after the preceding attempt finishes. Bracket each attempt's output +so an older success marker cannot satisfy the current attempt. The delay falls +between completed failures; a timeout returns without queuing another attempt. +The command may still be running after a timeout, until you cancel it or close +the temporary window. ```python >>> import time +>>> import uuid ->>> retry_window = session.new_window(window_name='retry-demo', attach=False) +>>> retry_window = session.new_window(window_name='retry-demo', attach=False, window_shell='sh') >>> retry_pane = retry_window.active_pane ->>> def retry_until_success(pane, command, success_marker, max_retries=3, delay=0.5): +>>> def retry_until_success(pane, command, success_marker, max_retries=3, delay=0.5, timeout=5.0): ... """Retry command until success marker appears.""" ... for attempt in range(max_retries): -... pane.send_keys(command) -... time.sleep(delay) -... output = '\\n'.join(pane.capture_pane()) -... if success_marker in output: -... return True, attempt + 1 +... begin = f'__ATTEMPT_{uuid.uuid4().hex}__' +... end = f'{begin}_END' +... pane.send_keys(fr'printf "\n%s\n" "{begin}"; {command}; printf "\n%s\n" "{end}"') +... deadline = time.monotonic() + timeout +... while time.monotonic() < deadline: +... lines = [line.rstrip(' ') for line in pane.capture_pane(start='-', join_wrapped=True)] +... try: +... first = lines.index(begin) +... last = lines.index(end, first + 1) +... except ValueError: +... time.sleep(0.05) +... continue +... if success_marker in lines[first + 1:last]: +... return True, attempt + 1 +... break +... else: +... return False, attempt + 1 +... if attempt + 1 < max_retries: +... time.sleep(delay) ... return False, max_retries ->>> # Test retry +>>> # The first attempt prints NOT OK; only the second prints the exact marker. +>>> command = ( +... 'libtmux_attempt=${libtmux_attempt:-0}; libtmux_attempt=$((libtmux_attempt+1)); ' +... r'if [ "$libtmux_attempt" -ge 2 ]; then printf "\nOK\n"; else printf "\nNOT OK\n"; fi' +... ) >>> success, attempts = retry_until_success( -... retry_pane, 'echo "OK"', 'OK', max_retries=3, delay=0.2 +... retry_pane, command, 'OK', max_retries=3, delay=0.2 ... ) >>> success True >>> attempts -1 +2 >>> # Clean up >>> retry_window.kill() @@ -433,36 +528,47 @@ genuinely a pipeline of steps, not a single call. ### Task queue processor A task queue runs a list of commands in order, waiting for each to finish before -starting the next. You tag every task with an indexed marker so you know exactly -which step you're waiting on, and you collect a pass/fail result per task. +starting the next. You tag every task with a fresh indexed marker so you know +which step you're waiting on, and stop on the first timeout. Each result records +completion before the deadline, not the command's exit status. ```python +>>> import shlex >>> import time +>>> import uuid ->>> queue_window = session.new_window(window_name='queue', attach=False) +>>> queue_window = session.new_window(window_name='queue', attach=False, window_shell='sh') >>> queue_pane = queue_window.active_pane ->>> def process_task_queue(pane, tasks, completion_marker='TASK_DONE'): +>>> def process_task_queue(pane, tasks, completion_marker='TASK_DONE', timeout=5.0): ... """Process a queue of tasks sequentially.""" ... results = [] +... completion_marker = f'{completion_marker}_{uuid.uuid4().hex}' ... for i, task in enumerate(tasks): -... pane.send_keys(f'{task}; echo "{completion_marker}_{i}"') +... marker = f'{completion_marker}_{i}' +... pane.send_keys(fr'{task}; printf "\n%s\n" {shlex.quote(marker)}') ... # Wait for this task to complete -... start = time.time() -... while time.time() - start < 5.0: -... output = '\\n'.join(pane.capture_pane()) -... if f'{completion_marker}_{i}' in output: +... deadline = time.monotonic() + timeout +... while time.monotonic() < deadline: +... if any(line.rstrip(' ') == marker for line in pane.capture_pane(join_wrapped=True)): ... results.append((i, True)) ... break -... time.sleep(0.1) +... time.sleep(0.05) ... else: ... results.append((i, False)) +... break ... return results >>> tasks = ['echo "Step 1"', 'echo "Step 2"', 'echo "Step 3"'] >>> results = process_task_queue(queue_pane, tasks) >>> all(success for _, success in results) True +>>> len(results) +3 + +>>> # Stop before submitting a second task when the first waits for input. +>>> process_task_queue(queue_pane, ['read response', 'echo "not submitted"'], timeout=0.1) +[(0, False)] >>> # Clean up >>> queue_window.kill() @@ -478,7 +584,7 @@ and the history tells you how far you got before it stopped. ```python >>> import time ->>> state_window = session.new_window(window_name='state-machine', attach=False) +>>> state_window = session.new_window(window_name='state-machine', attach=False, window_shell='sh') >>> state_pane = state_window.active_pane >>> def run_state_machine(pane, states, timeout_per_state=2.0): @@ -490,23 +596,22 @@ and the history tells you how far you got before it stopped. ... state_name, command, next_marker = states[current_state] ... pane.send_keys(command) ... -... start = time.time() -... while time.time() - start < timeout_per_state: -... output = '\\n'.join(pane.capture_pane()) -... if next_marker in output: +... deadline = time.monotonic() + timeout_per_state +... while time.monotonic() < deadline: +... if any(line.rstrip(' ') == next_marker for line in pane.capture_pane(join_wrapped=True)): ... history.append(state_name) ... current_state += 1 ... break -... time.sleep(0.1) +... time.sleep(0.05) ... else: ... return history, False # Timeout ... ... return history, True >>> states = [ -... ('init', 'echo "INIT_DONE"', 'INIT_DONE'), -... ('process', 'echo "PROCESS_DONE"', 'PROCESS_DONE'), -... ('cleanup', 'echo "CLEANUP_DONE"', 'CLEANUP_DONE'), +... ('init', r'printf "\nINIT_DONE\n"', 'INIT_DONE'), +... ('process', r'printf "\nPROCESS_DONE\n"', 'PROCESS_DONE'), +... ('cleanup', r'printf "\nCLEANUP_DONE\n"', 'CLEANUP_DONE'), ... ] >>> history, success = run_state_machine(state_pane, states) @@ -515,6 +620,10 @@ True >>> len(history) 3 +>>> blocked = [('blocked', r'read response; printf "\nUNREACHED\n"', 'UNREACHED')] +>>> run_state_machine(state_pane, blocked, timeout_per_state=0.1) +([], False) + >>> # Clean up >>> state_window.kill() ``` @@ -528,16 +637,20 @@ command finished, have it print an explicit marker and poll for that. Your autom then reacts to what actually happened rather than to a clock. ```python ->>> bp_window = session.new_window(window_name='best-practice', attach=False) +>>> bp_window = session.new_window(window_name='best-practice', attach=False, window_shell='sh') >>> bp_pane = bp_window.active_pane >>> # Good: Use completion marker ->>> bp_pane.send_keys('long_command; echo "__DONE__"') +>>> bp_pane.send_keys(r'sleep 0.1; printf "\n__DONE__\n"') >>> # Then poll for marker >>> import time ->>> time.sleep(0.2) ->>> '__DONE__' in '\\n'.join(bp_pane.capture_pane()) +>>> deadline = time.monotonic() + 2.0 +>>> while time.monotonic() < deadline: +... if any(line.rstrip(' ') == '__DONE__' for line in bp_pane.capture_pane(join_wrapped=True)): +... break +... time.sleep(0.05) +>>> any(line.rstrip(' ') == '__DONE__' for line in bp_pane.capture_pane(join_wrapped=True)) True >>> bp_window.kill() @@ -550,7 +663,7 @@ it. Tear down what you opened when you're done, so a long-running automation pro doesn't accumulate orphaned objects. ```python ->>> cleanup_window = session.new_window(window_name='cleanup-demo', attach=False) +>>> cleanup_window = session.new_window(window_name='cleanup-demo', attach=False, window_shell='sh') >>> cleanup_window # doctest: +ELLIPSIS Window(@... ...) @@ -570,7 +683,7 @@ is released whether the work succeeded or blew up. ```python >>> # Context managers ensure cleanup even on exceptions ->>> with session.new_window(window_name='safe-work') as safe_window: +>>> with session.new_window(window_name='safe-work', window_shell='sh') as safe_window: ... pane = safe_window.active_pane ... # Work happens here ... pass # Even if exception occurs, window is cleaned up diff --git a/docs/topics/context_managers.md b/docs/topics/context_managers.md index d42b16b8b0..a0d2575a8f 100644 --- a/docs/topics/context_managers.md +++ b/docs/topics/context_managers.md @@ -15,6 +15,12 @@ application, you typically let objects persist and tear them down yourself. The context-manager form earns its keep in test fixtures and short-lived scripts, where you want a tmux object to exist for exactly one block and then vanish. +Entering a handle's context opts into destruction even if the resource already +exists. In particular, `Server()` addresses the default daemon: its context +kills all sessions on that daemon. Use {meth}`~libtmux.Server.owned` for a +private server or {meth}`~libtmux.Server.owned_session` for a session the block +creates; see {ref}`owned-context-managers` below. + Open two terminals: Terminal one: start tmux in a separate terminal: @@ -29,15 +35,16 @@ Terminal two, `python` or `ptpython` if you have it: $ python ``` -Import `libtmux`: +Import {class}`~libtmux.Server`: ```python ->>> import libtmux +>>> from libtmux import Server ``` ## Server context manager -You create a temporary server that will be killed when you're done: +The context kills the addressed server when you're done, including any sessions +that existed before the block: ```python >>> with Server() as server: @@ -105,7 +112,11 @@ once and have every layer torn down for you: ... with window.split() as pane: ... pane.send_keys('echo "Hello"') ... # Do work with the pane -... # Everything is cleaned up automatically when exiting contexts +... assert pane not in window.panes +... assert window not in session.windows +... assert session not in server.sessions +>>> server.is_alive() +False ``` This ensures that: @@ -117,6 +128,23 @@ This ensures that: The cleanup happens in reverse order (pane → window → session → server), ensuring proper resource management. +## Cleanup after an exception + +The same cleanup runs when the body raises. The exception still reaches the +caller after the context exits: + +```python +>>> try: +... with server.new_session() as temporary: +... assert temporary in server.sessions +... raise RuntimeError("body failed") +... except RuntimeError as error: +... print(error) +body failed +>>> temporary in server.sessions +False +``` + ## Benefits Reaching for a context manager buys you a few things. Resources clean themselves @@ -128,6 +156,71 @@ even when an exception unwinds the stack — so you don't leak a stray session o pane on the error path. And when you nest contexts, the objects tear down in hierarchical order, which keeps tmux's own bookkeeping consistent. +(owned-context-managers)= + +## Explicit ownership + +Owned scopes create their resources instead of adopting an existing handle. +This makes the boundary useful when a script shares a tmux server with other +work. + +### Own a private server + +`Server.owned()` creates a private socket directory. The daemon starts when you +create the first session. Exiting the block kills the daemon at that private +endpoint and removes its directory, including when the body raises. This scope +never accepts an existing socket and defaults to an empty configuration. + +```python +>>> from libtmux.server import Server as TmuxServer +>>> with TmuxServer.owned() as temporary: +... created = temporary.new_session("build") +... temporary.is_alive() +True +>>> temporary.is_alive() +False +``` + +The scope keeps its original cleanup endpoint if the yielded handle is +reconfigured. If cleanup fails, the exception propagates and the directory +remains available for retry. A body exception remains in the exception chain. + +### Own one session + +`server.owned_session()` creates a detached session on an existing server. It +rejects an existing name and cleans up only the session it created. Other +sessions remain running. Cleanup follows the session ID after a rename; +deleting the session or replacing its daemon does not transfer ownership to +another resource. + +```python +>>> with server.owned_session("temporary") as created: +... created.rename_session("renamed") +Session($... renamed) +>>> server.has_session("renamed") +False +``` + +Creation and cleanup use the server's command timeout. A timeout raises +{exc}`~libtmux.exc.TmuxTimeout`; the command may already have taken effect. +Cleanup errors propagate instead of being interpreted as successful removal. + +### Contexts on looked-up handles + +The ordinary session, window and pane contexts also kill resources obtained +through lookup. Keep those handles outside a `with` block when you intend to +leave their resources running. Here, exiting a second handle's context kills +the session created before the block: + +```python +>>> created = server.new_session("lookup-context") +>>> with server.sessions.get(session_id=created.session_id) as looked_up: +... looked_up.session_id == created.session_id +True +>>> server.has_session("lookup-context") +False +``` + ## When to use Use context managers when you're writing test fixtures, running short-lived diff --git a/docs/topics/examples.md b/docs/topics/examples.md new file mode 100644 index 0000000000..0177d16b55 --- /dev/null +++ b/docs/topics/examples.md @@ -0,0 +1,75 @@ +(examples)= + +# Examples + +`examples/` in the repository root holds standalone scripts you run +directly, with tmux on `PATH` and no existing session required: + +```console +$ python examples/quickstart.py +``` + +Each one owns its own {class}`~libtmux.Server` via +{meth}`~libtmux.Server.owned`, so running it never touches a session you +already have open on your default socket. The suite executes every script +under `examples/` as its own subprocess (`tests/test_examples.py`), so an +example that stops running is a test failure, not a stale file. + +You can stop after `quickstart.py` for the object hierarchy itself. The rest +cover one topic each, in the order most readers reach for them. + +## Quickstart + +`quickstart.py` walks {class}`~libtmux.Server` → +{class}`~libtmux.Session` → {class}`~libtmux.Window` → +{class}`~libtmux.Pane`: create a session, send a command, and read back its +output. + +```{literalinclude} ../../examples/quickstart.py +:language: python +``` + +## Command results + +`command_results.py` runs tmux directly with +{func}`~libtmux.common.run_command`, for a one-off query or diagnostic where +you don't need an object to hold onto afterward. + +```{literalinclude} ../../examples/command_results.py +:language: python +``` + +## Owned scopes + +`owned_scopes.py` contrasts {meth}`~libtmux.Server.owned` (a private daemon +for the block) with {meth}`~libtmux.Server.owned_session` (one session on a +server you already hold). See {doc}`context_managers` for the cleanup rules +behind both. + +```{literalinclude} ../../examples/owned_scopes.py +:language: python +``` + +## Resilient automation + +`resilient_automation.py` bounds a call with a timeout and catches +{exc}`~libtmux.exc.TmuxTimeout`, then verifies a pane's decoded +{attr}`~libtmux.Pane.is_dead` instead of assuming a command finished because +nothing raised. See {doc}`automation_patterns` for the fuller pattern +catalog this is drawn from. + +```{literalinclude} ../../examples/resilient_automation.py +:language: python +``` + +## Polling for changes + +`polling_for_changes.py` answers a question the other examples sidestep: +how do you notice a change without an event stream? libtmux has none -- +{attr}`~libtmux.Session.windows` re-queries tmux on every access, so polling +it is the supported answer. See {doc}`public-vs-internal` for why this is +the answer rather than the internal `ControlMode` test client. + +```{literalinclude} ../../examples/polling_for_changes.py +:language: python +``` diff --git a/docs/topics/floating_panes.md b/docs/topics/floating_panes.md index 7aa96a6c3d..8a5c312796 100644 --- a/docs/topics/floating_panes.md +++ b/docs/topics/floating_panes.md @@ -47,14 +47,20 @@ which is `"1"` when it floats: You set the pane's **size** with `width` and `height` (tmux's `-x` / `-y`), and its **position** with `x` and `y` — cells measured from the top-left of the -window (tmux's `-X` / `-Y`). tmux reports the placement back through the +window (tmux's `-X` / `-Y`). On tmux 3.8+, these values include the border: +an 80-by-15 pane with a border has 78-by-13 content cells. tmux 3.7 uses +content dimensions directly. tmux reports the content position through the {attr}`pane_x ` / -{attr}`pane_y ` fields: +{attr}`pane_y ` fields, one cell inside a border on +tmux 3.8+. Disable borders when the requested position should match those +fields exactly: ```python >>> from libtmux.common import has_gte_version >>> if has_gte_version("3.7"): +... if has_gte_version("3.8"): +... _ = window.set_option("pane-border-lines", "none") ... placed = window.new_pane(width=20, height=5, x=2, y=1, shell="sleep 30") ... position = (placed.pane_x, placed.pane_y) ... else: diff --git a/docs/topics/index.md b/docs/topics/index.md index c955e5857e..a62dda3e15 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -79,6 +79,12 @@ Attached terminals, live-attachment lookup, and the view-vs-identity model. Scope- and version-gated typed fields on every libtmux object. ::: +:::{grid-item-card} Examples +:link: examples +:link-type: doc +Standalone scripts you run directly, executed by the test suite. +::: + :::: ```{toctree} @@ -99,4 +105,5 @@ context_managers options_and_hooks clients format-tokens +examples ``` diff --git a/docs/topics/public-vs-internal.md b/docs/topics/public-vs-internal.md index ade6b735c6..cffe80e088 100644 --- a/docs/topics/public-vs-internal.md +++ b/docs/topics/public-vs-internal.md @@ -54,10 +54,56 @@ implementation details you never need to understand to use libtmux: - {mod}`~libtmux._internal.dataclasses` — base dataclass utilities used by the ORM objects - {mod}`~libtmux._internal.constants` — internal constants not meaningful to end users - {mod}`~libtmux._internal.sparse_array` — the sparse-index mapping behind indexed hooks and options +- `libtmux._internal.control_mode` — spawns a real `tmux -C attach-session` + client so this project's own tests have one to exercise; see below These are documented in {ref}`internals` for contributors, but downstream projects should not import from them. +## libtmux has no streaming API + +There is no event stream: nothing in libtmux calls you back when a window +opens, a pane's process exits, or output arrives. `ControlMode` +(`libtmux._internal.control_mode.ControlMode`) looks like it might be one — +it spawns a `tmux -C attach-session` client, tmux's own control-mode +protocol — but it decodes none of that protocol. It exists so tests can +assert against a real attached client (`Server.list_clients()` needs one to +list, `display_popup()` needs one to run a popup's command against); reading +its `stdout` further is not something this class does or was built for. + +Given that, keeping it internal is not a placeholder for a public version +later — it is not a partial streaming API with a rough edge, it is not a +streaming API at all, and calling it one would promise a decoder that isn't +there. If a real control-mode client (`%output`, `%window-add`, +`%session-changed`, and the rest of tmux's event stream, actually parsed) +becomes a deliverable, it starts as a new module, not a promotion of this +one — a stability statement can't retroactively apply to code that already +shipped without one. Until then, this is where a reader looking for +"streaming" or "async" in libtmux should stop looking, and reach instead for: + +- **Polling.** `Session.windows`, `Window.panes`, and `Server.sessions` + re-query tmux on every access — nothing is cached. Wrap a check in + {func}`~libtmux.test.retry.retry_until` (also public, despite living under + `libtmux.test`) to wait for a condition rather than a fixed sleep. This is + the pattern the rest of the library is built around, including its own + test suite. +- **`Pane.pipe()`.** The closest thing to real streaming here: + {meth}`~libtmux.Pane.pipe` wraps `pipe-pane`, handing a pane's raw output + to an external command as tmux produces it, without libtmux in the loop + at all. +- **Hooks.** {mod}`~libtmux.hooks` runs a tmux command when a server-side + event fires — `window-linked`, `pane-died`, and the rest of the table. + It is still tmux invoking a command, not a Python callback, but it is + genuinely event-driven rather than polled. + +None of the three is async, and neither is anything else in libtmux: the +library has no `asyncio` integration anywhere in `src/`. Every call blocks +on a subprocess; `Server.timeout` and {meth}`~libtmux.Server.cmd`'s +per-call `timeout` bound how long, and `raise`d {exc}`~libtmux.exc.TmuxTimeout` +is how a caller finds out a bound was hit. `send_keys()` not waiting for its +command to finish is the one place non-blocking dispatch already exists — +polling is how you find out what happened next. + ## What `_vendor/` contains The `_vendor/` package holds vendored third-party code — copies of external diff --git a/examples/command_results.py b/examples/command_results.py new file mode 100644 index 0000000000..0b5cc012f0 --- /dev/null +++ b/examples/command_results.py @@ -0,0 +1,44 @@ +"""Run tmux commands directly with run_command() and CommandResult. + +:func:`~libtmux.common.run_command` executes tmux without an object hierarchy +in the way. Reach for it for one-off queries and diagnostics; reach for +``Server``/``Session``/``Window``/``Pane`` for anything you traverse or hold +onto. + +Run it as shown, with tmux on ``PATH``:: + + $ python examples/command_results.py +""" + +from __future__ import annotations + +from libtmux.common import CommandResult, run_command + + +def main() -> None: + """Run a command with run_command() and inspect the CommandResult.""" + result: CommandResult = run_command("-V") + + print(f"cmd: {result.cmd}") + print(f"returncode: {result.returncode}") + print(f"stdout: {result.stdout}") + + # A nonzero exit is still just data on the result, not an exception -- + # useful for probing whether a subcommand exists on this tmux version. + # -L names a socket that cannot already exist, so "no server running" + # is deterministic here regardless of whether the reader happens to + # have their own default-socket tmux running -- run_command() takes no + # socket by default, and probing without one would reach for it. + probe = run_command( + "-L", + "libtmux-examples-command-results-no-such-server", + "has-session", + "-t", + "definitely-not-a-real-session", + ) + print(f"probe returncode: {probe.returncode}") + print(f"probe stderr: {probe.stderr}") + + +if __name__ == "__main__": + main() diff --git a/examples/owned_scopes.py b/examples/owned_scopes.py new file mode 100644 index 0000000000..0cdf1a9fa1 --- /dev/null +++ b/examples/owned_scopes.py @@ -0,0 +1,41 @@ +"""Own a private daemon, or just one session on a server you already run. + +:meth:`~libtmux.Server.owned` creates a private socket for the block and +kills that daemon on exit -- nothing else on the machine can be listening on +it. :meth:`~libtmux.Server.owned_session` instead creates one session on a +``Server`` you already hold and kills only that session, leaving the rest of +the daemon (and any other sessions on it) alone. + +Run it as shown, with tmux on ``PATH``:: + + $ python examples/owned_scopes.py +""" + +from __future__ import annotations + +from libtmux.server import Server + + +def main() -> None: + """Contrast a private daemon scope with a single-session scope.""" + with Server.owned() as server: + # tmux itself only starts once the first session exists. + server.new_session(session_name="starts-the-daemon") + print(f"private daemon alive once a session exists: {server.is_alive()}") + + with server.owned_session("build") as build_session: + print(f"session name: {build_session.session_name!r}") + print(f"session exists during its block: {server.has_session('build')}") + + print(f"'build' cleaned up on exit: {not server.has_session('build')}") + print( + f"daemon still up -- owned_session killed only 'build': {server.is_alive()}" + ) + + # Server.owned()'s own cleanup already ran by here: the daemon is killed + # and its socket directory removed. + print(f"private daemon killed: {not server.is_alive()}") + + +if __name__ == "__main__": + main() diff --git a/examples/polling_for_changes.py b/examples/polling_for_changes.py new file mode 100644 index 0000000000..7b6a7833ec --- /dev/null +++ b/examples/polling_for_changes.py @@ -0,0 +1,47 @@ +"""Notice a new window without a subscription API -- libtmux has none. + +libtmux has no event stream: nothing calls you back when a window opens or a +pane's process exits. ``Session.windows`` re-queries tmux on every access, so +polling it is the supported way to notice a change -- ``retry_until()`` is a +small wrapper around exactly that loop. See docs/topics/public-vs-internal.md +for why this is the answer rather than the internal ``ControlMode`` test +client. + +Run it as shown, with tmux on ``PATH``:: + + $ python examples/polling_for_changes.py +""" + +from __future__ import annotations + +import threading + +from libtmux.server import Server +from libtmux.session import Session +from libtmux.test.retry import retry_until + + +def open_a_window_soon(session: Session) -> None: + """Simulate another process changing the session, from a thread.""" + session.new_window(window_name="opened-elsewhere") + + +def main() -> None: + """Poll a session's windows until one appears from elsewhere.""" + with Server.owned() as server: + session = server.new_session(session_name="watcher") + starting_count = len(session.windows) + + threading.Timer(0.3, open_a_window_soon, args=(session,)).start() + + def window_was_added() -> bool: + return len(session.windows) > starting_count + + retry_until(window_was_added, raises=True) + + names = [w.window_name for w in session.windows] + print(f"windows now: {names}") + + +if __name__ == "__main__": + main() diff --git a/examples/quickstart.py b/examples/quickstart.py new file mode 100644 index 0000000000..45f2ca52ad --- /dev/null +++ b/examples/quickstart.py @@ -0,0 +1,49 @@ +"""Server, Session, Window, and Pane, end to end. + +Run it as shown, with tmux on ``PATH`` and no existing session required:: + + $ python examples/quickstart.py + +Uses :meth:`~libtmux.Server.owned` for a private daemon on its own socket, +so this never touches a session you already have open on the default one. +""" + +from __future__ import annotations + +from libtmux.server import Server +from libtmux.test.retry import retry_until + + +def main() -> None: + """Walk the Server -> Session -> Window -> Pane hierarchy.""" + with Server.owned() as server: + # A plain POSIX shell reaches its prompt immediately, so the marker + # below shows up without waiting on a login shell's own startup. + session = server.new_session( + session_name="quickstart", + window_name="main", + window_command="sh", + ) + window = session.active_window + pane = window.active_pane + assert pane is not None + + pane.send_keys("echo hello-from-libtmux") + + # send_keys() returns as soon as the keys are sent, not once the + # shell has run them -- poll capture_pane() for the marker line + # rather than assuming it is already there. + def marker_is_visible() -> bool: + return any( + line.rstrip(" ") == "hello-from-libtmux" for line in pane.capture_pane() + ) + + retry_until(marker_is_visible, raises=True) + + print(f"session: {session.session_name!r}") + print(f"window: {window.window_name!r}") + print(f"pane output: {pane.capture_pane()[-2:]}") + + +if __name__ == "__main__": + main() diff --git a/examples/resilient_automation.py b/examples/resilient_automation.py new file mode 100644 index 0000000000..83661a50de --- /dev/null +++ b/examples/resilient_automation.py @@ -0,0 +1,58 @@ +"""Bound a command with a timeout, and verify state instead of assuming it. + +send_keys() returns as soon as the keys are sent, not once the shell has +acted on them, and a slow or hung command has no default deadline of its +own. This example gives one call an explicit timeout and catches +:exc:`~libtmux.exc.TmuxTimeout`, then checks a pane's decoded +:attr:`~libtmux.Pane.is_dead` instead of assuming a command completed +because nothing raised. + +Run it as shown, with tmux on ``PATH``:: + + $ python examples/resilient_automation.py +""" + +from __future__ import annotations + +from libtmux import exc +from libtmux.server import Server +from libtmux.test.retry import retry_until + + +def main() -> None: + """Bound one call with a timeout and confirm pane state afterward.""" + with Server.owned() as server: + # A plain POSIX shell reaches its prompt immediately, so markers show + # up without waiting on a login shell's own startup. + session = server.new_session(session_name="resilient", window_command="sh") + pane = session.active_pane + assert pane is not None + + # A per-call timeout only bounds this one command; the server and + # every other call remain unbounded unless Server(timeout=...) sets + # a default for all of them. + try: + server.cmd("wait-for", "a-signal-nobody-sends", timeout=0.5) + except exc.TmuxTimeout as e: + print(f"bounded call timed out as expected: {e}") + + # Whether or not that timed out, the pane itself was never touched -- + # verify that locally rather than assuming it from the exception. + pane.refresh() + print(f"pane still running: {pane.is_dead is False}") + + # A command that does complete: send it, then poll for its own + # completion marker rather than trusting that it already ran. + pane.send_keys("echo automation-marker-done") + retry_until( + lambda: any( + line.rstrip(" ") == "automation-marker-done" + for line in pane.capture_pane() + ), + raises=True, + ) + print("marker observed: command completed") + + +if __name__ == "__main__": + main() diff --git a/justfile b/justfile index 05557181a6..86ac0bfca4 100644 --- a/justfile +++ b/justfile @@ -35,6 +35,11 @@ watch-test: just _entr-warn fi +# Run the performance benchmarks (not part of the test suite; separate tier) +[group: 'benchmark'] +bench *args: + uv run pytest benchmarks/ -o python_files='bench_*.py' --benchmark-only {{ args }} + # Build documentation [group: 'docs'] build-docs: diff --git a/pyproject.toml b/pyproject.toml index 21d658f2b3..352e43e965 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,11 +67,13 @@ dev = [ "pytest-xdist", # Coverage "codecov", - "coverage", + "coverage>=7.10.6", "pytest-cov", # Lint "ruff>=0.16.1", "mypy", + # Benchmarking + "pytest-benchmark>=5.3.0", ] docs = [ @@ -90,7 +92,7 @@ testing = [ ] coverage =[ "codecov", - "coverage", + "coverage>=7.10.6", "pytest-cov", ] lint = [ @@ -98,6 +100,9 @@ lint = [ "ruff>=0.16.1", "mypy", ] +benchmark = [ + "pytest-benchmark>=5.3.0", +] [project.entry-points.pytest11] libtmux = "libtmux.pytest_plugin" @@ -146,10 +151,14 @@ python_version = "3.10" files = [ "src", "tests", + "examples", + "benchmarks", ] [tool.coverage.run] +source = ["src/libtmux"] +patch = ["subprocess"] branch = true parallel = true omit = [ @@ -170,7 +179,7 @@ exclude_lines = [ "def parse_args", "if TYPE_CHECKING:", "if t.TYPE_CHECKING:", - "@overload( |$)", + '@(?:t\.)?overload( |$)', 'class .*\bProtocol\):', "from __future__ import annotations", "import typing as t", @@ -261,8 +270,9 @@ convention = "numpy" ] "src/libtmux/server.py" = [ # `Server.is_alive` answers a yes/no question about an unreachable server. - # Every way of failing to reach it is a "no"; callers who need the reason - # use `Server.raise_if_dead`. + # Every way of failing to reach it is a "no", except a `TmuxTimeout` + # (re-raised before this catch-all: a wedged server is not a dead one). + # Callers who need the reason use `Server.raise_if_dead`. "BLE001", ] @@ -287,4 +297,5 @@ testpaths = [ ] markers = [ "integration: sphinx integration tests (require full sphinx build)", + "examples: runs a documented examples/*.py script end to end against a real tmux server (every test here already requires one, so this stays in the default run)", ] diff --git a/src/libtmux/AGENTS.md b/src/libtmux/AGENTS.md index 380707c714..b9df2b38c8 100644 --- a/src/libtmux/AGENTS.md +++ b/src/libtmux/AGENTS.md @@ -17,15 +17,46 @@ facts specific to this package. reconcile it, or use the `neo` query interface, which always queries fresh. -## List-returning accessors: empty by default on tmux errors - -`Server.sessions`, `Server.clients`, and `Server.attached_sessions` -return an empty `QueryList` when tmux's underlying list command fails -for any reason — no running daemon, a missing socket, a permission -error, a subprocess crash. This is a deliberate API contract: -list-shaped accessors are lenient by default. Callers that need to -distinguish "no rows" from "tmux unreachable" use the explicit -`Server.is_alive()` or `Server.raise_if_dead()` primitives. +## List-returning accessors: empty by default on tmux errors -- but not everywhere + +`Server.sessions`, `Server.clients` (and `Server.attached_sessions`, +which filters `.sessions`), and `Window.linked_sessions` return an +empty `QueryList` when tmux's underlying list *invocation* fails **for +any reason** — no running daemon, a missing socket, a permission +error, a subprocess crash. This is a deliberate API contract: these +are unconditionally lenient. `Window.linked_sessions` goes one step +further than the other two: it swallows a malformed-record parse +failure into `[]` too, where `Server.sessions`/`Server.clients` let +that one propagate (see below). + +`Server.windows` and `Server.panes` are also lenient, but narrower: +they collapse only a not-yet-started daemon or a missing socket +(`_is_daemon_not_up_error`) to empty, via `_fetch_or_empty`, and +propagate everything else — including a permission error, which +`Server.sessions`/`Server.clients` would still swallow. Do not assume +the two groups agree on what counts as "no rows". + +**`Session.windows`, `Session.panes`, `Window.panes`, and +`Window.search_panes` are not lenient at all.** Any tmux failure there +propagates as `LibTmuxException` (or a subclass) — there is no +empty-by-default contract below the server scope. A caller who has +seen `Server.sessions == []` on a dead server must not infer that a +`Session`/`Window` relation obtained beforehand will also read empty +rather than raise; it raises. Call `Server.is_alive()` or +`Server.raise_if_dead()` up front instead of inferring server health +from any single collection's emptiness. + +For `Server.sessions`, `Server.clients`, `Server.windows`, and +`Server.panes`, two exceptions propagate instead of collapsing to +empty, because neither means "no rows": `exc.TmuxRecordParseError` (the +invocation succeeded but a value contained the field separator, so the +reply itself could not be parsed — see `neo._split_records`) and +`exc.TmuxTimeout` (the command was killed mid-flight; whether it took +effect is unknown). Swallowing either would tell a caller "nothing to +list" when tmux may hold rows libtmux simply couldn't read back. +`exc.TmuxTimeout` is not a `LibTmuxException` subclass, so every +accessor above already lets it propagate unconditionally, including +`Window.linked_sessions`. When adding a new list-returning accessor, follow this convention. If a future feature genuinely benefits from loud-failure semantics, expose diff --git a/src/libtmux/__init__.py b/src/libtmux/__init__.py index c99fde4bda..dff3615a11 100644 --- a/src/libtmux/__init__.py +++ b/src/libtmux/__init__.py @@ -14,6 +14,7 @@ __title__, __version__, ) +from ._internal.query_list import QueryList from .client import Client from .pane import Pane from .server import Server @@ -25,6 +26,7 @@ __all__ = ( "Client", "Pane", + "QueryList", "Server", "Session", "Window", diff --git a/src/libtmux/_internal/constants.py b/src/libtmux/_internal/constants.py index df4d9843f2..93cc3977f4 100644 --- a/src/libtmux/_internal/constants.py +++ b/src/libtmux/_internal/constants.py @@ -834,6 +834,50 @@ class Hooks( Runs after 'unbind-key' completes. command_error : SparseArray[str] Runs when a command fails (tmux 3.5+). + after_swap_window : SparseArray[str] + Runs after swapping windows. Requires tmux 3.8+. + client_closed : SparseArray[str] + Runs when a client closes. Requires tmux 3.8+. + client_created : SparseArray[str] + Runs when a client is created. Requires tmux 3.8+. + marked_pane_changed : SparseArray[str] + Runs when the marked pane changes. Requires tmux 3.8+. + pane_activity : SparseArray[str] + Runs when pane output arrives. Requires tmux 3.8+. + pane_bell : SparseArray[str] + Runs when a pane receives a bell. Requires tmux 3.8+. + pane_command_finished : SparseArray[str] + Runs when an OSC 133 command finishes. Requires tmux 3.8+. + pane_command_started : SparseArray[str] + Runs when an OSC 133 command starts. Requires tmux 3.8+. + pane_created : SparseArray[str] + Runs when a pane is created or respawned. Requires tmux 3.8+. + pane_mode_entered : SparseArray[str] + Runs when a pane enters a mode. Requires tmux 3.8+. + pane_mode_exited : SparseArray[str] + Runs when a pane leaves a mode. Requires tmux 3.8+. + pane_moved : SparseArray[str] + Runs when a pane moves between windows. Requires tmux 3.8+. + pane_prompt_closed : SparseArray[str] + Runs when a pane prompt closes. Requires tmux 3.8+. + pane_prompt_opened : SparseArray[str] + Runs when a pane prompt opens. Requires tmux 3.8+. + pane_resized : SparseArray[str] + Runs when a pane changes size. Requires tmux 3.8+. + pane_shell_prompt : SparseArray[str] + Runs when an OSC 133 shell prompt starts. Requires tmux 3.8+. + session_added_to_group : SparseArray[str] + Runs when a session joins a group. Requires tmux 3.8+. + session_removed_from_group : SparseArray[str] + Runs when a session leaves a group. Requires tmux 3.8+. + window_closed : SparseArray[str] + Runs when a window closes. Requires tmux 3.8+. + window_created : SparseArray[str] + Runs when a window is created. Requires tmux 3.8+. + window_unzoomed : SparseArray[str] + Runs when a window leaves zoom mode. Requires tmux 3.8+. + window_zoomed : SparseArray[str] + Runs when a window enters zoom mode. Requires tmux 3.8+. Examples -------- @@ -1093,6 +1137,29 @@ class Hooks( # Runs when a command fails (tmux 3.5+) command_error: SparseArray[str] = field(default_factory=SparseArray) + after_swap_window: SparseArray[str] = field(default_factory=SparseArray) + client_closed: SparseArray[str] = field(default_factory=SparseArray) + client_created: SparseArray[str] = field(default_factory=SparseArray) + marked_pane_changed: SparseArray[str] = field(default_factory=SparseArray) + pane_activity: SparseArray[str] = field(default_factory=SparseArray) + pane_bell: SparseArray[str] = field(default_factory=SparseArray) + pane_command_finished: SparseArray[str] = field(default_factory=SparseArray) + pane_command_started: SparseArray[str] = field(default_factory=SparseArray) + pane_created: SparseArray[str] = field(default_factory=SparseArray) + pane_mode_entered: SparseArray[str] = field(default_factory=SparseArray) + pane_mode_exited: SparseArray[str] = field(default_factory=SparseArray) + pane_moved: SparseArray[str] = field(default_factory=SparseArray) + pane_prompt_closed: SparseArray[str] = field(default_factory=SparseArray) + pane_prompt_opened: SparseArray[str] = field(default_factory=SparseArray) + pane_resized: SparseArray[str] = field(default_factory=SparseArray) + pane_shell_prompt: SparseArray[str] = field(default_factory=SparseArray) + session_added_to_group: SparseArray[str] = field(default_factory=SparseArray) + session_removed_from_group: SparseArray[str] = field(default_factory=SparseArray) + window_closed: SparseArray[str] = field(default_factory=SparseArray) + window_created: SparseArray[str] = field(default_factory=SparseArray) + window_unzoomed: SparseArray[str] = field(default_factory=SparseArray) + window_zoomed: SparseArray[str] = field(default_factory=SparseArray) + @classmethod def from_stdout(cls, value: list[str]) -> Hooks: """Parse raw tmux hook output into a Hooks instance. diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index 05945451eb..f0ad9d5af9 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -1,13 +1,15 @@ """Control-mode client context manager for tmux testing. Provides a context manager that spawns a ``tmux -C attach-session`` -subprocess, creating a real tmux client that satisfies commands -requiring an attached client (e.g. ``display-popup``, ``detach-client``). +subprocess, creating a real tmux client for commands such as +``detach-client``. Popups require a terminal client to run their commands. """ from __future__ import annotations +import contextlib import os +import signal import subprocess import typing as t @@ -26,8 +28,14 @@ class ControlMode: """Context manager that spawns a tmux control-mode client. Creates a real client attached to the session, visible in - ``Server.list_clients()``. The client communicates via the tmux - control protocol on stdout. + ``Server.list_clients()``. tmux writes its control-mode protocol to the + client's stdout, exposed here verbatim via :attr:`stdout` -- this class + decodes none of it. It exists so tests have a real attached client + (some assertions, and some tmux commands such as popups, require one), + not to give callers a parsed event stream. Internal + (``libtmux._internal``): no stability guarantee, use the public + ``control_mode`` pytest fixture instead of importing this class + directly. While active, ``Server.list_clients()`` will include this client. @@ -116,14 +124,8 @@ def client_registered() -> bool: try: retry_until(client_registered, 3, raises=True) - except Exception: - os.close(self._write_fd) - self._proc.terminate() - try: - self._proc.wait(timeout=5) - except subprocess.TimeoutExpired: - self._proc.kill() - self._proc.wait() + except BaseException: + self._stop() raise return self @@ -134,13 +136,26 @@ def __exit__( exc_val: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: - """Terminate control-mode client.""" - # Close write end — causes the control-mode client to exit (EOF on stdin) - os.close(self._write_fd) + """Terminate the control-mode client and close its streams.""" + self._stop() - self._proc.terminate() + def _stop(self) -> None: try: - self._proc.wait(timeout=5) - except subprocess.TimeoutExpired: - self._proc.kill() - self._proc.wait() + os.close(self._write_fd) + self._proc.terminate() + # A client stopped (e.g. SIGSTOP, a debugger, a frozen cgroup) + # cannot process SIGTERM until resumed, so the wait below would + # otherwise time out unconditionally. SIGCONT lets a stopped + # process actually see the pending SIGTERM and exit promptly; a + # running process ignores it. + with contextlib.suppress(ProcessLookupError): + self._proc.send_signal(signal.SIGCONT) + try: + self._proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait() + finally: + self.stdout.close() + if self._proc.stderr is not None: + self._proc.stderr.close() diff --git a/src/libtmux/_internal/query_list.py b/src/libtmux/_internal/query_list.py index 20aeb407f6..d44881a396 100644 --- a/src/libtmux/_internal/query_list.py +++ b/src/libtmux/_internal/query_list.py @@ -2,7 +2,8 @@ Note ---- -This is an internal API not covered by versioning policy. +Only :class:`QueryList`, exported as ``libtmux.QueryList``, is public. +The remaining helpers are internal and not covered by versioning policy. """ from __future__ import annotations @@ -34,6 +35,7 @@ def __call__( T = t.TypeVar("T") +D = t.TypeVar("D") no_arg = object() @@ -327,7 +329,8 @@ def __init__(self, op: str, *args: object) -> None: class QueryList(list[T], t.Generic[T]): """Filter list of object/dictionaries. For small, local datasets. - *Experimental, unstable*. + Import the public collection with ``from libtmux import QueryList``. + Filtering and cardinality checks operate on the existing list without I/O. **With dictionaries**: @@ -549,12 +552,36 @@ def val_match(obj: str | list[t.Any] | T) -> bool: return self.__class__(k for k in self if filter_(k)) + @t.overload def get( self, matcher: Callable[[T], bool] | T | None = None, - default: t.Any | None = no_arg, + *, + default: D, **kwargs: t.Any, - ) -> T | None: + ) -> T | D: ... + + @t.overload + def get( + self, + matcher: Callable[[T], bool] | T | None, + default: D, + **kwargs: t.Any, + ) -> T | D: ... + + @t.overload + def get( + self, + matcher: Callable[[T], bool] | T | None = None, + **kwargs: t.Any, + ) -> T: ... + + def get( + self, + matcher: Callable[[T], bool] | T | None = None, + default: object = no_arg, + **kwargs: t.Any, + ) -> object: """Retrieve exactly one object. Parameters @@ -583,7 +610,7 @@ def get( Examples -------- - >>> from libtmux._internal.query_list import QueryList + >>> from libtmux import QueryList >>> from libtmux import exc >>> qs = QueryList([{"pane_id": "%0"}, {"pane_id": "%0"}, {"pane_id": "%1"}]) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 2871547700..9d579b67d4 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -7,6 +7,7 @@ from __future__ import annotations +import dataclasses import functools import logging import re @@ -31,6 +32,13 @@ #: Most recent version of tmux supported TMUX_MAX_VERSION = "3.7" +#: Bound on draining stdout/stderr after :meth:`subprocess.Popen.kill`. +#: SIGKILL ends the killed process itself immediately, but a surviving +#: descendant that inherited its pipe file descriptors can keep them open +#: indefinitely, which would otherwise make ``communicate()`` block past +#: the caller's own deadline while waiting for EOF that never comes. +_KILL_REAP_TIMEOUT = 1.0 + SessionDict = dict[str, t.Any] WindowDict = dict[str, t.Any] WindowOptionDict = dict[str, t.Any] @@ -242,7 +250,7 @@ def getenv(self, name: str) -> str | bool | None: return opts_dict.get(name) -def raise_if_stderr(proc: tmux_cmd, subcommand: str) -> None: +def raise_if_stderr(proc: tmux_cmd | CommandResult, subcommand: str) -> None: """Raise :exc:`LibTmuxException` tagged with the tmux subcommand on stderr. Centralizes the ``if proc.stderr: raise exc.LibTmuxException(proc.stderr)`` @@ -280,9 +288,182 @@ def raise_if_stderr(proc: tmux_cmd, subcommand: str) -> None: ) +@dataclasses.dataclass() +class CommandResult: + """Captured output of a completed command; construction performs no I/O. + + A completed nonzero exit remains result data. ``process`` is the already + reaped subprocess retained for compatibility and process metadata. + + Attributes + ---------- + cmd : list[str] + Executable and arguments after string conversion. + stdout : list[str] + UTF-8 output with invalid bytes escaped; trailing empty lines removed. + stderr : list[str] + UTF-8 diagnostics with invalid bytes escaped; empty lines removed. + returncode : int + Completed process exit status. + process : subprocess.Popen[str] + Completed child process. + """ + + cmd: list[str] + stdout: list[str] + stderr: list[str] + returncode: int + process: subprocess.Popen[str] = dataclasses.field(repr=False, compare=False) + + +def run_command( + *args: object, + tmux_bin: str | None = None, + timeout: float | None = None, +) -> CommandResult: + """Run a command and capture its completed result. + + Parameters + ---------- + *args : object + tmux arguments, converted to strings without shell interpretation. + tmux_bin : str, optional + Executable path. Defaults to the first ``tmux`` on ``PATH``. + timeout : float, optional + Seconds to wait. ``None`` waits indefinitely. A non-positive value + is rejected rather than accepted and silently skipping the command + (see ``Raises``). + + Returns + ------- + CommandResult + Captured output and exit status, including completed nonzero exits. + + Raises + ------ + ValueError + *timeout* is not ``None`` and is less than or equal to zero. + ``subprocess.Popen.communicate(timeout=0)`` (or a negative value) + does not run the command at all -- the freshly spawned process has + not had a chance to respond, so it always reads as expired -- which + silently discarded a call meant to run, e.g. + :meth:`~libtmux.Server.wait_for` with ``signal=True``. + :exc:`~libtmux.exc.TmuxCommandNotFound` + The executable cannot be found. + :exc:`~libtmux.exc.TmuxTimeout` + The deadline elapsed. The child is killed and reaped before raising; + the command may already have taken effect. + + Notes + ----- + Preserves :class:`tmux_cmd` output conventions, including copying the + first stderr line to stdout for a failed ``has-session`` with no stdout. + + Examples + -------- + >>> result = run_command("-V") + >>> isinstance(result, CommandResult) + True + >>> result.returncode + 0 + """ + if timeout is not None and timeout <= 0: + msg = f"timeout must be positive or None, got {timeout!r}" + raise ValueError(msg) + + resolved = tmux_bin or shutil.which("tmux") + if not resolved: + raise exc.TmuxCommandNotFound + + cmd = [str(value) for value in (resolved, *args)] + + if logger.isEnabledFor(logging.DEBUG): + cmd_str = shlex.join(cmd) + logger.debug( + "tmux command dispatched", + extra={"tmux_cmd": cmd_str}, + ) + + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="backslashreplace", + ) + stdout, stderr = process.communicate(timeout=timeout) + returncode = process.returncode + except subprocess.TimeoutExpired: + # Kill and reap before raising. A caller that gives up on an + # unbounded call leaves the child running, so repeated + # timeouts accumulate tmux clients that nothing is waiting on. + process.kill() + try: + process.communicate(timeout=_KILL_REAP_TIMEOUT) + except subprocess.TimeoutExpired: + # A descendant inherited the stdout/stderr pipes and kept its + # own copy open, so reading for EOF would block indefinitely + # even though the killed process is already gone. Close our + # ends to stop waiting on it, then reap the process itself. + if process.stdout is not None: + process.stdout.close() + if process.stderr is not None: + process.stderr.close() + process.wait() + raise exc.TmuxTimeout(cmd, t.cast("float", timeout)) from None + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + except Exception: + logger.error( # noqa: TRY400 + "tmux subprocess failed", + extra={ + "tmux_cmd": shlex.join(cmd), + }, + ) + raise + + stdout_split = stdout.split("\n") + # remove trailing newlines from stdout + while stdout_split and stdout_split[-1] == "": + stdout_split.pop() + + stderr_split = stderr.split("\n") + stderr_lines = list(filter(None, stderr_split)) # filter empty values + + if "has-session" in cmd and len(stderr_lines) and not stdout_split: + stdout_lines = [stderr_lines[0]] + else: + stdout_lines = stdout_split + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "tmux command completed", + extra={ + "tmux_cmd": shlex.join(cmd), + "tmux_exit_code": returncode, + "tmux_stdout": stdout_lines[:100], + "tmux_stderr": stderr_lines[:100], + "tmux_stdout_len": len(stdout_lines), + "tmux_stderr_len": len(stderr_lines), + }, + ) + + return CommandResult( + cmd=cmd, + stdout=stdout_lines, + stderr=stderr_lines, + returncode=t.cast("int", returncode), + process=process, + ) + + class tmux_cmd: """Run any :term:`tmux(1)` command through :py:mod:`subprocess`. + Compatibility facade for :func:`run_command`, preserving result attributes. + Examples -------- Create a new session, check for error: @@ -303,79 +484,41 @@ class tmux_cmd: $ tmux new-session -s my session + Parameters + ---------- + tmux_bin : str, optional + Path to the tmux binary. Defaults to the first ``tmux`` on ``PATH``. + timeout : float, optional + Seconds to wait for the command. On expiry the tmux process is + killed and reaped, then :exc:`~libtmux.exc.TmuxTimeout` is + raised. ``None`` waits indefinitely. + + Raises + ------ + ValueError + ``timeout`` is not ``None`` and is less than or equal to zero. + :exc:`~libtmux.exc.TmuxTimeout` + ``timeout`` elapsed. The command may or may not have taken + effect -- the process was killed mid-command. + Notes ----- .. versionchanged:: 0.8 Renamed from ``tmux`` to ``tmux_cmd``. """ - def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: - resolved = tmux_bin or shutil.which("tmux") - if not resolved: - raise exc.TmuxCommandNotFound - - cmd = [resolved] - cmd += args # add the command arguments to cmd - cmd = [str(c) for c in cmd] - - self.cmd = cmd - - if logger.isEnabledFor(logging.DEBUG): - cmd_str = shlex.join(cmd) - logger.debug( - "tmux command dispatched", - extra={"tmux_cmd": cmd_str}, - ) - - try: - self.process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="backslashreplace", - ) - stdout, stderr = self.process.communicate() - returncode = self.process.returncode - except FileNotFoundError: - raise exc.TmuxCommandNotFound from None - except Exception: - logger.error( # noqa: TRY400 - "tmux subprocess failed", - extra={ - "tmux_cmd": shlex.join(cmd), - }, - ) - raise - - self.returncode = returncode - - stdout_split = stdout.split("\n") - # remove trailing newlines from stdout - while stdout_split and stdout_split[-1] == "": - stdout_split.pop() - - stderr_split = stderr.split("\n") - self.stderr = list(filter(None, stderr_split)) # filter empty values - - if "has-session" in cmd and len(self.stderr) and not stdout_split: - self.stdout = [self.stderr[0]] - else: - self.stdout = stdout_split - - if logger.isEnabledFor(logging.DEBUG): - logger.debug( - "tmux command completed", - extra={ - "tmux_cmd": shlex.join(cmd), - "tmux_exit_code": self.returncode, - "tmux_stdout": self.stdout[:100], - "tmux_stderr": self.stderr[:100], - "tmux_stdout_len": len(self.stdout), - "tmux_stderr_len": len(self.stderr), - }, - ) + def __init__( + self, + *args: t.Any, + tmux_bin: str | None = None, + timeout: float | None = None, + ) -> None: + result = run_command(*args, tmux_bin=tmux_bin, timeout=timeout) + self.cmd = result.cmd + self.stdout = result.stdout + self.stderr = result.stderr + self.returncode = result.returncode + self.process = result.process class _TmuxVersionUnavailable(Exception): diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 57bb06102f..4f2f912c83 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -350,6 +350,49 @@ class WaitTimeout(LibTmuxException): """Function timed out without meeting condition.""" +class TmuxTimeout(Exception): + """A tmux command did not return within its timeout. + + Deliberately NOT a :exc:`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. + + Distinct from :exc:`WaitTimeout`, which is a helper giving up on a + condition. This one means the tmux process was killed mid-command, + so nothing can be said about whether it took effect. + """ + + def __init__(self, cmd: list[str], timeout: float, *args: object) -> None: + self.cmd = cmd + self.timeout = timeout + # Forward the constructor's own arguments, not a pre-formatted + # message, so self.args stays shaped like __init__'s signature. + # pickle/copy reconstruct via `type(e)(*e.args)`; a lone message + # string there would mismatch this signature and raise TypeError. + super().__init__(cmd, timeout, *args) + + def __str__(self) -> str: + """Render the deadline and the command that missed it.""" + return f"tmux did not return within {self.timeout}s: {' '.join(self.cmd)}" + + +class TmuxRecordParseError(LibTmuxException): + """A ``list-*`` record could not be split into its fields. + + Raised by ``libtmux.neo._split_records`` when a value contains the + field separator itself, so the output no longer divides evenly into + whole records. + + Deliberately not absorbed by the list-returning accessors' + empty-by-default contract: this means libtmux received a reply it + cannot interpret, not that tmux was unreachable, so treating it like + "nothing to list" would hide real rows behind a parsing bug rather + than a connectivity gap. + """ + + class VariableUnpackingError(LibTmuxException): """Error unpacking variable.""" diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 98ece86fa5..690eea293a 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -882,6 +882,15 @@ def _refresh( the same precondition explicitly so the guarantee survives ``python -O``, where an ``assert`` would be stripped. + :func:`parse_output` drops empty values, so *obj* only carries the + fields tmux reported non-empty. Every field the live *list_cmd*/ + version's template queries is set here, not only the ones present in + *obj*: a field absent from the row is a field tmux now reports + empty, and must clear to ``None`` rather than keep its previous + value -- otherwise a title cleared with ``select-pane -T ''`` or a + dead pane's now-empty ``#{pane_pid}`` would read as stale data + forever. + Raises ------ ValueError @@ -901,8 +910,10 @@ def _refresh( ) assert obj is not None if obj is not None: - for k, v in obj.items(): - setattr(self, k, v) + tmux_version = str(get_version(tmux_bin=self.server.tmux_bin)) + fields, _ = get_output_format(list_cmd, tmux_version) + for k in fields: + setattr(self, k, obj.get(k)) @functools.cache @@ -1036,6 +1047,58 @@ def parse_output( return {k: v for k, v in formatter.items() if v} +def _split_records(stdout: list[str], field_count: int) -> list[str]: + """Regroup ``-F`` output into one string per object. + + tmux writes one record per line, but any format value may itself + contain a newline -- ``pane_current_path`` for a directory whose + name has one -- and that splits the record across output lines. + Iterating lines then hands :func:`parse_output` a fragment with too + few values, which its strict ``zip`` rejects, so one directory + breaks every object on the server rather than the one pane in it. + + Regrouping on the separator is exact rather than merely better: the + template from :func:`get_output_format` terminates *every* field + with a separator, so one record holds exactly ``field_count`` of + them and a newline is never one. Nothing is split on newlines, so a + value may contain any number of them, in any position. + + Raises + ------ + :exc:`~libtmux.exc.TmuxRecordParseError` + If the values do not divide into whole records, which means a + value contained the separator itself. + """ + blob = "\n".join(stdout) + if not blob: + return [] + + values = blob.split(FORMAT_SEPARATOR) + # Every record ends with a separator, so the split always leaves one + # trailing empty for the final record. + if values[-1] == "": + values.pop() + + if field_count <= 0 or len(values) % field_count: + msg = ( + f"tmux output could not be parsed: {len(values)} values for " + f"{field_count} fields per record. A format value probably " + f"contains the field separator ({FORMAT_SEPARATOR!r})." + ) + raise exc.TmuxRecordParseError(msg) + + records: list[str] = [] + for start in range(0, len(values), field_count): + chunk = values[start : start + field_count] + # The newline that terminated the previous record survives the + # join glued to this record's first value. It is a delimiter, + # not data. + if start and chunk[0].startswith("\n"): + chunk[0] = chunk[0][1:] + records.append(FORMAT_SEPARATOR.join(chunk) + FORMAT_SEPARATOR) + return records + + def fetch_objs( server: Server, list_cmd: ListCmd, @@ -1083,6 +1146,11 @@ def fetch_objs( ------ :exc:`~libtmux.exc.LibTmuxException` If the tmux command writes to stderr. + :exc:`~libtmux.exc.TmuxRecordParseError` + If a returned value contains the field separator, so the output + cannot be regrouped into whole records. + :exc:`~libtmux.exc.TmuxTimeout` + If the command does not return within ``server.timeout``. Examples -------- @@ -1133,11 +1201,15 @@ def fetch_objs( proc = tmux_cmd( *tmux_cmds, tmux_bin=server.tmux_bin, + timeout=server.timeout, ) raise_if_stderr(proc, list_cmd) - outputs = [parse_output(line, list_cmd, tmux_version) for line in proc.stdout] + outputs = [ + parse_output(record, list_cmd, tmux_version) + for record in _split_records(proc.stdout, len(_fields)) + ] if logger.isEnabledFor(logging.DEBUG): if cmd_str is None: diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index e0c2f59619..c65034ebc8 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -132,6 +132,9 @@ def __exit__( ) -> None: """Exit the context, killing the pane if it exists. + This also destroys a pane obtained through lookup, not only one + created in this process. Keep borrowed handles outside a ``with`` block. + Parameters ---------- exc_type : type[BaseException] | None @@ -607,14 +610,28 @@ def capture_pane( list[str] or None Captured pane content, or ``None`` when *to_buffer* is set. + Raises + ------ + :exc:`libtmux.exc.LibTmuxException` + If tmux returns an error, e.g. the pane no longer exists + (``can't find pane: ...``). Pass ``quiet=True`` for tmux's own + ``-q`` (suppress errors silently) if that is not wanted. + Examples -------- >>> pane = window.split(shell='sh') + >>> retry_until(lambda: "$" in "\n".join(pane.capture_pane()), 2) + True >>> pane.capture_pane() ['$'] >>> pane.send_keys('echo "Hello world"', enter=True) + >>> def command_finished(): + ... lines = pane.capture_pane() + ... return len(lines) >= 2 and lines[-2:] == ['Hello world', '$'] + >>> retry_until(command_finished, 2) + True >>> pane.capture_pane() ['$ echo "Hello world"', 'Hello world', '$'] @@ -687,6 +704,7 @@ def capture_pane( stacklevel=2, ) proc = self.cmd(*cmd) + raise_if_stderr(proc, "capture-pane") if to_buffer is not None: return None return proc.stdout @@ -770,6 +788,9 @@ def send_keys( ValueError If ``cmd`` is ``None`` and no flag-only path is selected (``reset``, ``repeat``, or ``copy_mode_cmd``). + :exc:`libtmux.exc.LibTmuxException` + If tmux returns an error, e.g. the pane no longer exists + (``can't find pane: ...``). Examples -------- @@ -830,7 +851,8 @@ def send_keys( if copy_mode_cmd is not None: tmux_args += ("-X",) - self.cmd("send-keys", *tmux_args, copy_mode_cmd) + proc = self.cmd("send-keys", *tmux_args, copy_mode_cmd) + raise_if_stderr(proc, "send-keys") elif cmd is None: # Flag-only path — tmux's cmd-send-keys.c:223-225 explicitly # supports count == 0 when -R or -N is set, returning @@ -841,10 +863,12 @@ def send_keys( "reset=True, repeat=N, copy_mode_cmd=..." ) raise ValueError(msg) - self.cmd("send-keys", *tmux_args) + proc = self.cmd("send-keys", *tmux_args) + raise_if_stderr(proc, "send-keys") return else: - self.cmd("send-keys", *tmux_args, prefix + cmd) + proc = self.cmd("send-keys", *tmux_args, prefix + cmd) + raise_if_stderr(proc, "send-keys") if enter and copy_mode_cmd is None: self.enter() @@ -1397,15 +1421,7 @@ def split( pane_cmd = self.cmd("split-window", *tmux_args, target=target) - if pane_cmd.stderr: - if "pane too small" in pane_cmd.stderr: - raise exc.LibTmuxException(pane_cmd.stderr) - - raise exc.LibTmuxException( - pane_cmd.stderr, - self.__dict__, - self.window.panes, - ) + raise_if_stderr(pane_cmd, "split-window") pane_output = pane_cmd.stdout[0] @@ -1471,12 +1487,16 @@ def new_pane( Environment variables for the new pane (``-e`` flag). width : int, optional Width of the floating pane in cells (``-x`` flag). + Includes the border on tmux 3.8+; ``pane_width`` reports content cells. height : int, optional Height of the floating pane in cells (``-y`` flag). + Includes the border on tmux 3.8+; ``pane_height`` reports content cells. x : int, optional X position of the floating pane in cells (``-X`` flag). + Places the outer border on tmux 3.8+; ``pane_x`` reports content position. y : int, optional Y position of the floating pane in cells (``-Y`` flag). + Places the outer border on tmux 3.8+; ``pane_y`` reports content position. zoom : bool, optional Zoom the pane (``-Z`` flag). empty : bool, optional @@ -1656,8 +1676,14 @@ def enter(self) -> Pane: """Send carriage return to pane. ``$ tmux send-keys`` send Enter to the pane. + + Raises + ------ + :exc:`libtmux.exc.LibTmuxException` + If tmux returns an error, e.g. the pane no longer exists. """ - self.cmd("send-keys", "Enter") + proc = self.cmd("send-keys", "Enter") + raise_if_stderr(proc, "send-keys") return self def display_popup( @@ -1684,9 +1710,9 @@ def display_popup( ) -> None: """Display a popup overlay via ``$ tmux display-popup``. - Requires tmux 3.2+ and an attached client. Use - :class:`~libtmux._internal.control_mode.ControlMode` in tests to provide - a client. + Requires tmux 3.2+ and an attached terminal client to display the + popup and run its command. A control-mode client can accept this + request without executing the popup command. Parameters ---------- @@ -1736,12 +1762,11 @@ def display_popup( Examples -------- - Not directly testable — popup rendering requires a TTY-backed client. - Control-mode provides an attached client for invocation but the popup - itself is not visible or verifiable. + This control-mode client has no popup. The close request returns + without changing its state: >>> with control_mode() as ctl: - ... pane.display_popup(command='true', close_on_exit=True) + ... pane.display_popup(close_existing=True, target_client=ctl.client_name) """ if close_on_exit and close_on_success: msg = ( @@ -2615,12 +2640,17 @@ def reset(self) -> Pane: freshly-cleared grid between the terminal-state reset and the history clear. + Raises + ------ + :exc:`libtmux.exc.LibTmuxException` + If tmux returns an error, e.g. the pane is gone. + Examples -------- >>> pane.reset() Pane(%... Window(@... ...:..., Session($1 libtmux_...))) """ - self.server.cmd( + proc = self.server.cmd( "send-keys", "-t", self.pane_id, @@ -2630,6 +2660,9 @@ def reset(self) -> Pane: "-t", self.pane_id, ) + + raise_if_stderr(proc, "send-keys") + return self # @@ -2708,6 +2741,97 @@ def title(self) -> str | None: """ return self.pane_title + @property + def width_cells(self) -> int | None: + """Captured width in character cells, or ``None`` when unavailable. + + Reads locally. The existing :attr:`width` alias retains its raw string. + """ + return int(self.pane_width) if self.pane_width is not None else None + + @property + def height_cells(self) -> int | None: + """Captured height in character cells, or ``None`` when unavailable. + + Reads locally. The existing :attr:`height` alias retains its raw string. + """ + return int(self.pane_height) if self.pane_height is not None else None + + @property + def left_cells(self) -> int | None: + """Captured left edge, as a window column, or ``None`` when unavailable. + + Reads locally. :attr:`pane_left` retains the raw string. + """ + return int(self.pane_left) if self.pane_left is not None else None + + @property + def top_cells(self) -> int | None: + """Captured top edge, as a window row, or ``None`` when unavailable. + + Reads locally. :attr:`pane_top` retains the raw string. + """ + return int(self.pane_top) if self.pane_top is not None else None + + @property + def is_active(self) -> bool | None: + """Captured active flag within the window, or ``None`` when unavailable. + + Reads locally; zero is false and a nonzero integer is true. + """ + return bool(int(self.pane_active)) if self.pane_active is not None else None + + @property + def is_dead(self) -> bool | None: + """Captured pane-process exit flag, or ``None`` when unavailable. + + Reads locally, like :attr:`is_active` -- never re-queries tmux. A + stale handle keeps reporting whatever it last captured; call + :meth:`refresh` first for a live answer. That answer also depends + on ``remain-on-exit``: without it, tmux destroys a pane outright + when its process exits, so there is no "dead" state to read, only + a handle whose :meth:`refresh` now raises + :exc:`~libtmux.exc.TmuxObjectDoesNotExist`. + + Examples + -------- + A stale handle answers from its last snapshot, not from tmux. The + pane exits just after :meth:`Window.split` reads it back, so the + read-back itself never races the exit: + + >>> gone = window.split(shell="sh -c 'sleep 1; exit 0'") + >>> retry_until( + ... lambda: len(window.panes.filter(pane_id=gone.pane_id)) == 0, 3 + ... ) + True + >>> gone.is_dead # last snapshot said "alive"; never re-queried + False + + Refreshing that same handle raises -- the pane wasn't merely + marked dead, tmux removed it (no ``remain-on-exit``): + + >>> from libtmux import exc + >>> try: + ... gone.refresh() + ... except exc.TmuxObjectDoesNotExist: + ... print("destroyed, not merely dead") + destroyed, not merely dead + + With ``remain-on-exit``, the pane survives and a refreshed handle + reports it: + + >>> stays = window.split(shell="sh") + >>> stays.cmd("set-option", "-p", "remain-on-exit", "on") # doctest: +HIDE + + >>> stays.send_keys("exit", enter=True) + >>> def _stays_dead() -> bool | None: + ... stays.refresh() + ... return stays.is_dead + >>> retry_until(_stays_dead, 2) + True + """ + return bool(int(self.pane_dead)) if self.pane_dead is not None else None + @property def at_top(self) -> bool: """Typed, converted wrapper around :attr:`Pane.pane_at_top`. diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index fcc3ce052d..5c1b9d05d3 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -309,7 +309,13 @@ def control_mode( spawn a control-mode tmux client. While the control-mode client is active, ``Server.list_clients()`` - will include it. + will include it. It decodes none of tmux's control-mode protocol -- + only ``client_name`` and raw ``stdout`` are exposed -- so this is a + real attached client for tests that need one, not a streaming API. This + fixture is part of the public pytest plugin surface; ``ControlMode`` + itself stays in ``libtmux._internal`` and can change without notice. + See :doc:`/topics/public-vs-internal` for what to use instead of a + streaming API. Examples -------- diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e650557c34..fde10696a9 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -7,11 +7,14 @@ from __future__ import annotations +import contextlib import logging import os import pathlib import shutil +import signal import subprocess +import tempfile import typing as t import warnings @@ -22,7 +25,7 @@ from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import OptionScope from libtmux.hooks import HooksMixin -from libtmux.neo import fetch_objs, get_output_format, parse_output +from libtmux.neo import _split_records, fetch_objs, get_output_format, parse_output from libtmux.pane import Pane from libtmux.session import Session from libtmux.window import Window @@ -38,6 +41,7 @@ if t.TYPE_CHECKING: import types + from collections.abc import Iterator from typing import TypeAlias from typing_extensions import Self @@ -49,6 +53,33 @@ logger = logging.getLogger(__name__) +class _NotSet: + """Sentinel for an omitted ``timeout`` argument, distinct from ``None``. + + :meth:`Server.cmd`'s ``timeout`` has three meanings: omitted (fall back + to :attr:`Server.timeout`), ``None`` (run this one call unbounded, even + when the server has a timeout), or a number (override it). ``None`` as + the default would erase the second meaning by making it indistinguishable + from the first. + """ + + def __repr__(self) -> str: + return "" + + +_NOT_SET = _NotSet() + +#: Termination signals :meth:`Server.owned` traps so its cleanup still runs. +#: SIGINT already becomes ``KeyboardInterrupt`` by Python's own default +#: handler; these two do not raise anything by default, so their default +#: disposition (process death) never reaches this scope's ``finally``. +#: ``SIGHUP`` does not exist on Windows. +_OWNED_TERMINATION_SIGNALS: tuple[signal.Signals, ...] = ( + signal.SIGTERM, + *((signal.SIGHUP,) if hasattr(signal, "SIGHUP") else ()), +) + + def _is_daemon_not_up_error(stderr_text: str) -> bool: """Return True if the error indicates the tmux server is not running. @@ -82,6 +113,39 @@ def _fetch_or_empty( raise +def _session_identity_predicate(session: Session) -> tuple[str, str]: + """Return ``(session_id, predicate)`` identifying a freshly created session. + + ``predicate`` is a tmux format expression true only for a session with + this exact id *and* this exact pid/start_time generation, so a later + check against it cannot match a same-named replacement that reused the + id after this one was killed. + + Raises + ------ + :exc:`~libtmux.exc.LibTmuxException` + ``session`` is missing an id, pid, or start_time -- a bare + ``assert`` would vanish under ``python -O`` and let a ``None`` + reach the f-strings below as the literal text ``"None"``. + """ + if session.session_id is None: + msg = "New session has no session_id" + raise exc.LibTmuxException(msg) + if session.pid is None: + msg = "New session has no pid" + raise exc.LibTmuxException(msg) + if session.start_time is None: + msg = "New session has no start_time" + raise exc.LibTmuxException(msg) + session_id = f"${int(session.session_id.removeprefix('$'))}" + pid = int(session.pid) + started = int(session.start_time) + generation = f"#{{&&:#{{==:#{{pid}},{pid}}},#{{==:#{{start_time}},{started}}}}}" + exists = f"#{{S:#{{?#{{==:#{{session_id}},{session_id}}},1,}}}}" + predicate = f"#{{&&:{generation},{exists}}}" + return session_id, predicate + + class Server( EnvironmentMixin, OptionsMixin, @@ -166,6 +230,9 @@ class Server( """For hook management.""" tmux_bin: str | None = None """Custom path to tmux binary. Falls back to ``shutil.which("tmux")``.""" + timeout: float | None = None + """Seconds to wait for a command before raising + :exc:`~libtmux.exc.TmuxTimeout`. ``None`` waits indefinitely.""" def __init__( self, @@ -176,15 +243,21 @@ def __init__( on_init: t.Callable[[Server], None] | None = None, socket_name_factory: t.Callable[[], str] | None = None, tmux_bin: str | pathlib.Path | None = None, + timeout: float | None = None, **kwargs: t.Any, ) -> None: EnvironmentMixin.__init__(self, "-g") self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None + self.timeout = timeout self._windows: list[WindowDict] = [] self._panes: list[PaneDict] = [] if socket_path is not None: - self.socket_path = socket_path + # str, not the pathlib.Path the type also accepts: __eq__ + # compares socket_path by value, and Path("/x") != "/x", so a + # Path here would make an otherwise-identical endpoint compare + # unequal to one addressed by string. + self.socket_path = str(socket_path) elif socket_name is not None: self.socket_name = socket_name elif socket_name_factory is not None: @@ -257,6 +330,154 @@ def from_env(cls, env: t.Mapping[str, str] | None = None) -> Server: """ return cls(socket_path=socket_path_from_env(env)) + @classmethod + @contextlib.contextmanager + def owned( + cls, + *, + config_file: str = os.devnull, + tmux_bin: str | pathlib.Path | None = None, + timeout: float | None = None, + ) -> Iterator[Self]: + """Own a private server endpoint for the duration of a block. + + Creates a private socket directory on entry. tmux starts when the + first session is created. On exit, kills that endpoint's server and + removes the directory. No existing endpoint can be supplied. + + Parameters + ---------- + config_file : str, optional + Configuration for the new daemon; defaults to an empty config. + tmux_bin : str or Path, optional + Executable path; defaults to ``tmux`` on ``PATH``. + timeout : float, optional + Per-command timeout, including cleanup, in seconds. + + Yields + ------ + Server + Server addressed by the private socket. + + Notes + ----- + Cleanup retains its original endpoint if the yielded handle changes. + A cleanup failure propagates and leaves the socket directory available + for retry; a body exception remains in the exception chain. + + SIGTERM and SIGHUP are trapped for the scope's duration, on the + main thread, when nothing has already installed a handler or + ignored them: Python already turns SIGINT into + ``KeyboardInterrupt``, which the cleanup above catches like any + other exception, but SIGTERM (``timeout``, ``kill``, a cancelled + CI job, ``docker stop``, systemd) and SIGHUP (closing the + terminal) do not raise anything by default -- their default + disposition ends the interpreter without unwinding, which used to + leave the private daemon and socket directory behind. Trapping + them runs this method's own cleanup from the handler itself, then + restores the signal's default disposition and re-raises it against + this process, so the process still dies by the signal -- a parent + sees a signal exit (e.g. ``-15``), not exit code ``143``/``129`` -- + and no ``except`` anywhere in the block, however broad, can keep it + running: nothing here depends on a Python exception unwinding + through the block's own code to reach cleanup. This also means + only this endpoint's cleanup runs; anything else the block would + have unwound through (the caller's own ``finally``/``with`` + blocks) does not get a chance to, same as if the signal had never + been trapped at all. A caller that wants its own graceful shutdown + on these signals installs its own handler before entering the + scope -- ``owned()`` only installs where the target had its default + disposition (a caller-installed handler or an explicit ignore is + left alone) -- and is restored on exit if nothing inside the block + replaced it with something else. + + Examples + -------- + >>> from libtmux.server import Server as TmuxServer + >>> with TmuxServer.owned() as temporary: + ... created = temporary.new_session("build") + ... temporary.is_alive() + True + >>> temporary.is_alive() + False + """ + directory = pathlib.Path(tempfile.mkdtemp(prefix="libtmux-owned-")) + socket_path = directory / "socket" + + previous_handlers: dict[signal.Signals, t.Any] = {} + + def _cleanup() -> None: + """Restore trapped signals and remove the owned endpoint. + + Re-callable: each step guards itself (a handler still set to + ``_terminate``, a socket that still exists, a directory that + still exists), so calling this twice -- once from a trapped + signal, once more from the ``finally`` below as the exception + that signal raised unwinds back into this generator -- redoes + only whatever the first call didn't finish. A failure here + (e.g. ``kill-server`` itself fails) propagates and leaves + whatever is left for the next call to retry. + """ + for sig, previous in previous_handlers.items(): + if signal.getsignal(sig) is _terminate: + # `previous` is always SIG_DFL here (see the install + # loop below), so this is also what a trapped signal's + # handler relies on to restore default disposition + # before re-raising itself. + signal.signal(sig, previous) + if socket_path.exists(): + proc = Server( + socket_path=socket_path, + tmux_bin=tmux_bin, + timeout=timeout, + ).cmd("kill-server") + if (proc.returncode or proc.stderr) and not _is_daemon_not_up_error( + " ".join(proc.stderr) + ): + raise exc.LibTmuxException( + proc.stderr or f"Server cleanup exited with {proc.returncode}" + ) + if directory.exists(): + shutil.rmtree(directory) + + def _terminate(signum: int, frame: object) -> None: + # Clean up from the handler itself rather than raising an + # exception for the block to unwind through: a broad + # ``except`` anywhere in the block would otherwise catch that + # exception and keep the process running past a signal meant + # to end it. Disposition is back to SIG_DFL once _cleanup() + # returns, so this re-raise terminates the process by the + # signal, the same as if it had never been trapped. + _cleanup() + os.kill(os.getpid(), signum) + + for sig in _OWNED_TERMINATION_SIGNALS: + try: + current = signal.getsignal(sig) + except (ValueError, OSError): + continue # not the main thread, or unsupported here + if current is not signal.SIG_DFL: + # Caller already handles or ignores it: leave it alone. + # Overriding a handler would change the caller's own + # shutdown behavior; SIG_IGN means nothing terminates the + # process from this signal to begin with. + continue + try: + signal.signal(sig, _terminate) + except (ValueError, OSError): + continue + previous_handlers[sig] = current + + try: + yield cls( + socket_path=socket_path, + config_file=config_file, + tmux_bin=tmux_bin, + timeout=timeout, + ) + finally: + _cleanup() + def __enter__(self) -> Self: """Enter the context, returning self. @@ -275,6 +496,9 @@ def __exit__( ) -> None: """Exit the context, killing the server if it exists. + This legacy scope also kills a daemon that existed before entry. + Use :meth:`owned` to create and own a private endpoint instead. + Parameters ---------- exc_type : type[BaseException] | None @@ -284,7 +508,17 @@ def __exit__( exc_tb : types.TracebackType | None The traceback of the exception that was raised """ - if self.is_alive(): + try: + alive = self.is_alive() + except exc.TmuxTimeout: + # A wedged server answers neither "alive" nor "dead". Assume + # alive and attempt the kill rather than skip it: a live + # server left unkilled is a silent leak, while a kill attempt + # against a truly dead one is a cheap, harmless no-op. If the + # server really is wedged, kill() will itself time out and + # raise -- a loud leak the caller can act on. + alive = True + if alive: self.kill() def is_alive(self) -> bool: @@ -292,9 +526,20 @@ def is_alive(self) -> bool: >>> tmux = Server(socket_name="no_exist") >>> assert not tmux.is_alive() + + Raises + ------ + :exc:`~libtmux.exc.TmuxTimeout` + The command did not return within :attr:`Server.timeout`. + Unlike every other way of failing to reach the server, this is + not treated as "no" -- a wedged server is not a dead one, and + a caller told "dead" may go on to start a second server + alongside one that is merely slow to answer. """ try: res = self.cmd("list-sessions") + except exc.TmuxTimeout: + raise except Exception: return False return res.returncode == 0 @@ -302,13 +547,19 @@ def is_alive(self) -> bool: def raise_if_dead(self) -> None: """Raise if server not connected. + Routed through :meth:`Server.cmd`, so this honors + :attr:`Server.timeout` like every other command instead of + blocking indefinitely against a wedged server. + Raises ------ - :exc:`exc.TmuxCommandNotFound` + :exc:`~libtmux.exc.TmuxCommandNotFound` When the tmux binary cannot be found or executed. :class:`subprocess.CalledProcessError` When the tmux server is not running (non-zero exit from ``list-sessions``). + :exc:`~libtmux.exc.TmuxTimeout` + The command did not return within :attr:`Server.timeout`. >>> tmux = Server(socket_name="no_exist") >>> try: @@ -317,22 +568,14 @@ def raise_if_dead(self) -> None: ... print(type(e)) """ - resolved = self.tmux_bin or shutil.which("tmux") - if resolved is None: - raise exc.TmuxCommandNotFound - - cmd_args: list[str] = ["list-sessions"] - if self.socket_name: - cmd_args.insert(0, f"-L{self.socket_name}") - if self.socket_path: - cmd_args.insert(0, f"-S{self.socket_path}") - if self.config_file: - cmd_args.insert(0, f"-f{self.config_file}") - - try: - subprocess.check_call([resolved, *cmd_args]) - except FileNotFoundError: - raise exc.TmuxCommandNotFound from None + proc = self.cmd("list-sessions") + if proc.returncode != 0: + raise subprocess.CalledProcessError( + proc.returncode, + proc.cmd, + output="\n".join(proc.stdout), + stderr="\n".join(proc.stderr), + ) # # Command @@ -342,6 +585,7 @@ def cmd( cmd: str, *args: t.Any, target: str | int | None = None, + timeout: float | _NotSet | None = _NOT_SET, ) -> tmux_cmd: """Execute tmux command respective of socket name and file, return output. @@ -379,11 +623,23 @@ def cmd( ---------- target : str, optional Optional custom target. + timeout : float, optional + Per-call override for :attr:`Server.timeout`. Omit to use the + server's timeout; pass ``None`` to run this one call without a + bound even when the server has one; pass a number to bound just + this call. Zero or negative is rejected (see ``Raises``) rather + than accepted and silently never running the command. Returns ------- :class:`common.tmux_cmd` + Raises + ------ + ValueError + *timeout* (or the resolved :attr:`Server.timeout`) is not + ``None`` and is less than or equal to zero. + Notes ----- .. versionchanged:: 0.8 @@ -408,7 +664,14 @@ def cmd( cmd_args = ["-t", str(target), *args] if target is not None else [*args] - return tmux_cmd(*svr_args, *cmd_args, tmux_bin=self.tmux_bin) + resolved_timeout = self.timeout if isinstance(timeout, _NotSet) else timeout + + return tmux_cmd( + *svr_args, + *cmd_args, + tmux_bin=self.tmux_bin, + timeout=resolved_timeout, + ) @property def attached_sessions(self) -> list[Session]: @@ -623,7 +886,9 @@ def wait_for( *, lock: bool | None = None, unlock: bool | None = None, + signal: bool | None = None, set_flag: bool | None = None, + timeout: float | _NotSet | None = _NOT_SET, ) -> None: """Wait for, signal, or lock a channel via ``$ tmux wait-for``. @@ -633,17 +898,73 @@ def wait_for( Channel name. lock : bool, optional Lock the channel (``-L`` flag). + + .. warning:: + + A locker bounded by *timeout* that times out does not give + the lock back up: tmux hands a pending lock to the next + queued locker on unlock regardless of whether that locker is + still waiting, so a caller that gave up still receives it, + and every later ``wait_for(channel, lock=True)`` on that + channel times out in turn -- there is no way to lock it + again. This is a tmux limitation (``cmd-wait-for.c``'s + ``cmd_wait_for_unlock``), not particular to this method; it + only becomes reachable once a lock wait can be bounded at + all. Use a fresh channel name after a timed-out lock wait, + not the same one. unlock : bool, optional Unlock the channel (``-U`` flag). + signal : bool, optional + Set the channel flag and wake waiters (``-S`` flag) -- tmux's + own manual calls this "signal". + + .. versionadded:: 0.63 set_flag : bool, optional - Set the channel flag and wake waiters (``-S`` flag). + Deprecated alias for *signal*. + + .. deprecated:: 0.63 + + Use *signal* instead. + timeout : float, optional + Per-call override for :attr:`Server.timeout`, like + :meth:`Server.cmd`'s. Omit to use the server's timeout; pass + ``None`` to wait without a bound even when the server has one; + pass a number to bound just this call. Zero or negative is + rejected (see ``Raises``): it would never run the command -- + not even ``signal=True``'s non-blocking ``-S``. + + .. versionadded:: 0.63 + + Without a *timeout* here or on :attr:`Server.timeout` + (``None`` by default), a channel that is never signalled + blocks the caller forever -- there was previously no way + to bound this call at all. + + Raises + ------ + ValueError + *timeout* is not ``None`` and is less than or equal to zero. + :exc:`libtmux.exc.LibTmuxException` + If tmux returns an error. + :exc:`libtmux.exc.TmuxTimeout` + If *timeout* (or :attr:`Server.timeout`) elapses before the + channel is signalled. Examples -------- >>> server.new_session(session_name='wait_test') Session(...) - >>> server.wait_for('test_channel', set_flag=True) + >>> server.wait_for('test_channel', signal=True) """ + if set_flag is not None: + warnings.warn( + "set_flag is deprecated in favor of signal", + category=DeprecationWarning, + stacklevel=2, + ) + if signal is None: + signal = set_flag + tmux_args: tuple[str, ...] = () if lock: @@ -652,12 +973,12 @@ def wait_for( if unlock: tmux_args += ("-U",) - if set_flag: + if signal: tmux_args += ("-S",) tmux_args += (channel,) - proc = self.cmd("wait-for", *tmux_args) + proc = self.cmd("wait-for", *tmux_args, timeout=timeout) raise_if_stderr(proc, "wait-for") @@ -892,11 +1213,21 @@ def server_access( tmux_args: tuple[str, ...] = () + # tmux's own arg spec (`cmd-server-access.c`) declares "adlrw" as + # value-less flags and takes the user as a single trailing + # positional -- `server-access -a myuser -r` is two positional + # arguments ("myuser", "-r") once getopt sees the first bare word, + # and tmux rejects it as "too many arguments". Every flag must come + # before the positional user. + user: str | None = None + if allow is not None: - tmux_args += ("-a", allow) + tmux_args += ("-a",) + user = allow if deny is not None: - tmux_args += ("-d", deny) + tmux_args += ("-d",) + user = deny if list_access: tmux_args += ("-l",) @@ -907,6 +1238,9 @@ def server_access( if write: tmux_args += ("-w",) + if user is not None: + tmux_args += (user,) + proc = self.cmd("server-access", *tmux_args) raise_if_stderr(proc, "server-access") @@ -1353,9 +1687,8 @@ def display_menu( """Display a popup menu via ``$ tmux display-menu``. Requires a TTY-backed attached client. Control-mode clients have - ``tty.sy=0``, which causes ``menu_prepare()`` to return NULL. - This method cannot be tested with - :class:`~libtmux._internal.control_mode.ControlMode`. + ``tty.sy=0``, which causes ``menu_prepare()`` to return NULL, so this + project's own control-mode test client cannot exercise this call. Parameters ---------- @@ -1509,10 +1842,10 @@ def show_messages( Without ``-T``/``-J``, tmux resolves the message log against a target client; if no client is attached and *target_client* is - omitted, tmux raises ``no current client``. Provide - *target_client* (e.g. via :class:`~libtmux._internal.control_mode.ControlMode`) - when running headless, or use *terminals*/*jobs* — those modes - don't require a client. + omitted, tmux raises ``no current client``. Provide *target_client* + (the ``client_name`` of any attached client, e.g. one from + ``tmux -C attach-session``) when running headless, or use + *terminals*/*jobs* — those modes don't require a client. Parameters ---------- @@ -1607,8 +1940,9 @@ def display_message( With no client attached and ``target_client`` omitted, the status-line path (``get_text=False``) issues a ``no current client`` warning. Use - ``get_text=True`` for headless reads, or pair with - :class:`~libtmux._internal.control_mode.ControlMode`. + ``get_text=True`` for headless reads, or attach a real client first + (e.g. ``tmux -C attach-session``) and pass its ``client_name`` as + ``target_client``. Notes ----- @@ -2377,7 +2711,12 @@ def new_session( raise_if_stderr(proc, "new-session") - session_stdout = proc.stdout[0] + # Regroup on the separator, not on stdout's lines: a format + # value (e.g. pane_current_path, via start_directory) may + # itself contain a newline, which would otherwise split this + # one record across proc.stdout and hand parse_output a + # truncated fragment. See _split_records for the mechanism. + session_stdout = _split_records(proc.stdout, len(_fields))[0] finally: if env: @@ -2396,6 +2735,94 @@ def new_session( return session + @contextlib.contextmanager + def owned_session( + self, + session_name: str | None = None, + *, + start_directory: StrPath | None = None, + window_name: str | None = None, + window_command: str | None = None, + environment: dict[str, str] | None = None, + ) -> Iterator[Session]: + """Create a detached session and kill only that session on exit. + + Existing names are rejected. Cleanup follows the created session's + ID after a rename and leaves a replacement session or daemon alone. + Deleting the session inside the block makes cleanup a no-op. + + Parameters + ---------- + session_name : str, optional + Name for the new session; tmux chooses one when omitted. + start_directory : str or PathLike, optional + Working directory for the initial window. + window_name : str, optional + Name for the initial window. + window_command : str, optional + Command for the initial window. + environment : dict[str, str], optional + Environment variables for the new session. + + Yields + ------ + Session + The newly created session. + + Raises + ------ + :exc:`~libtmux.exc.TmuxSessionExists` + The requested name already exists; it is never adopted or killed. + :exc:`~libtmux.exc.LibTmuxException` + Creation or cleanup fails. + :exc:`~libtmux.exc.TmuxTimeout` + A command exceeds this server's timeout. Its effects may be unknown. + + Examples + -------- + >>> with server.owned_session("temporary") as created: + ... created.session_name + 'temporary' + >>> server.has_session("temporary") + False + """ + cleanup_server = Server( + socket_name=self.socket_name, + socket_path=self.socket_path, + tmux_bin=self.tmux_bin, + timeout=self.timeout, + ) + session = self.new_session( + session_name, + start_directory=start_directory, + window_name=window_name, + window_command=window_command, + environment=environment, + ) + try: + session_id, predicate = _session_identity_predicate(session) + except Exception: + # The identity guard never finished building, so there is no + # predicate to check it against on the way out. Kill directly + # by identity instead of leaking the session -- no user code + # has run yet, so the reuse race the guard exists for below + # cannot have happened. + session.kill() + raise + try: + yield session + finally: + # Check identity and kill within tmux's synchronous command queue. + proc = cleanup_server.cmd( + "if-shell", "-F", predicate, f"kill-session -t {session_id}" + ) + if (proc.returncode or proc.stderr) and not _is_daemon_not_up_error( + " ".join(proc.stderr) + ): + raise exc.LibTmuxException( + proc.stderr or f"Session cleanup exited with {proc.returncode}" + ) + # # Relations # @@ -2408,16 +2835,23 @@ def sessions(self) -> QueryList[Session]: :meth:`.sessions.filter() ` Returns an empty :class:`~libtmux._internal.query_list.QueryList` when - tmux's ``list-sessions`` fails for any reason — no running daemon, a - missing socket, a permission error, or a subprocess failure. To - distinguish "no sessions" from "tmux unreachable", call + tmux's ``list-sessions`` invocation fails for any reason — no running + daemon, a missing socket, a permission error, or a subprocess + failure. To distinguish "no sessions" from "tmux unreachable", call :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. + + Does *not* absorb a :exc:`~libtmux.exc.TmuxRecordParseError` (the + invocation succeeded but its output could not be parsed) or a + :exc:`~libtmux.exc.TmuxTimeout` (unknown whether it took effect) — + both propagate, since neither means "no sessions". """ try: sessions: list[Session] = [ Session(server=self, **obj) for obj in fetch_objs(server=self, list_cmd="list-sessions") ] + except exc.TmuxRecordParseError: + raise except exc.LibTmuxException: return QueryList([]) return QueryList(sessions) @@ -2429,6 +2863,13 @@ def windows(self) -> QueryList[Window]: Can be accessed via :meth:`.windows.get() ` and :meth:`.windows.filter() ` + + Returns an empty :class:`~libtmux._internal.query_list.QueryList` + when the server has not started yet or its socket is missing. + Narrower than :attr:`Server.sessions`/:attr:`Server.clients`: any + *other* failure (a permission error, for example) propagates as + :exc:`~libtmux.exc.LibTmuxException` instead of collapsing to + empty. See ``AGENTS.md``'s "List-returning accessors" section. """ windows: list[Window] = [ Window(server=self, **obj) @@ -2448,6 +2889,10 @@ def panes(self) -> QueryList[Pane]: Can be accessed via :meth:`.panes.get() ` and :meth:`.panes.filter() ` + + Same narrower leniency as :attr:`Server.windows`: empty only for a + not-yet-started server or a missing socket; other failures + propagate. See ``AGENTS.md``'s "List-returning accessors" section. """ panes: list[Pane] = [ Pane(server=self, **obj) @@ -2469,10 +2914,16 @@ def clients(self) -> QueryList[Client]: ``client.client_session`` etc. read tmux's ``client_*`` format tokens. Returns an empty :class:`~libtmux._internal.query_list.QueryList` when - tmux's ``list-clients`` fails for any reason — no running daemon, a - missing socket, a permission error, or a subprocess failure. To - distinguish "no clients attached" from "tmux unreachable", call - :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. + tmux's ``list-clients`` invocation fails for any reason — no running + daemon, a missing socket, a permission error, or a subprocess + failure. To distinguish "no clients attached" from "tmux + unreachable", call :meth:`Server.is_alive` or + :meth:`Server.raise_if_dead`. + + Does *not* absorb a :exc:`~libtmux.exc.TmuxRecordParseError` (the + invocation succeeded but its output could not be parsed) or a + :exc:`~libtmux.exc.TmuxTimeout` (unknown whether it took effect) — + both propagate, since neither means "no clients". Returns ------- @@ -2490,6 +2941,8 @@ def clients(self) -> QueryList[Client]: Client(server=self, **obj) for obj in fetch_objs(server=self, list_cmd="list-clients") ] + except exc.TmuxRecordParseError: + raise except exc.LibTmuxException: return QueryList([]) return QueryList(clients) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 4277052a37..45607ecb9b 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -134,6 +134,9 @@ def __exit__( ) -> None: """Exit the context, killing the session if it exists. + This legacy behavior also applies to handles obtained through lookup. + Use :meth:`Server.owned_session` for a scope that creates its resource. + Parameters ---------- exc_type : type[BaseException] | None @@ -266,6 +269,11 @@ def windows(self) -> QueryList[Window]: Can be accessed via :meth:`.windows.get() ` and :meth:`.windows.filter() ` + + Unlike :attr:`Server.windows`, not lenient: any tmux failure here + (including a dead server) propagates as + :exc:`~libtmux.exc.LibTmuxException` rather than collapsing to an + empty list. See ``AGENTS.md``'s "List-returning accessors" section. """ windows: list[Window] = [ Window(server=self.server, **obj) @@ -286,6 +294,11 @@ def panes(self) -> QueryList[Pane]: Can be accessed via :meth:`.panes.get() ` and :meth:`.panes.filter() ` + + Unlike :attr:`Server.panes`, not lenient: any tmux failure here + propagates as :exc:`~libtmux.exc.LibTmuxException` rather than + collapsing to an empty list. See ``AGENTS.md``'s "List-returning + accessors" section. """ panes: list[Pane] = [ Pane(server=self.server, **obj) @@ -1032,6 +1045,14 @@ def name(self) -> str | None: """ return self.session_name + @property + def attached_count(self) -> int | None: + """Captured attached-client count, or ``None`` when unavailable. + + Reads locally. :attr:`session_attached` retains the raw tmux string. + """ + return int(self.session_attached) if self.session_attached is not None else None + # # Legacy: Redundant stuff we want to remove # diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b57db99692..24fcf1aeae 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -10,6 +10,7 @@ import dataclasses import logging import pathlib +import re import shlex import typing as t import warnings @@ -148,6 +149,9 @@ def __exit__( ) -> None: """Exit the context, killing the window if it exists. + This also destroys a window obtained through lookup, not only one + created in this process. Keep borrowed handles outside a ``with`` block. + Parameters ---------- exc_type : type[BaseException] | None @@ -385,6 +389,11 @@ def panes(self) -> QueryList[Pane]: Can be accessed via :meth:`.panes.get() ` and :meth:`.panes.filter() ` + + Unlike :attr:`Server.panes`, not lenient: any tmux failure here + propagates as :exc:`~libtmux.exc.LibTmuxException` rather than + collapsing to an empty list. See ``AGENTS.md``'s "List-returning + accessors" section. """ panes: list[Pane] = [ Pane(server=self.server, **obj) @@ -406,7 +415,9 @@ def search_panes( """Panes in this window, optionally filtered by tmux. Like :attr:`Window.panes` but with a ``filter`` kwarg passed to - ``$ tmux list-panes -t -f ``. + ``$ tmux list-panes -t -f ``. Not lenient, like + :attr:`Window.panes`: any tmux failure propagates as + :exc:`~libtmux.exc.LibTmuxException`. Parameters ---------- @@ -655,12 +666,16 @@ def new_pane( Environment variables for the new pane (``-e``). width : int, optional Width in cells (``-x``). + Includes borders on tmux 3.8+. height : int, optional Height in cells (``-y``). + Includes borders on tmux 3.8+. x : int, optional X position in cells (``-X``). + Places the outer border on tmux 3.8+. y : int, optional Y position in cells (``-Y``). + Places the outer border on tmux 3.8+. zoom : bool, optional Zoom the pane (``-Z``). empty : bool, optional @@ -863,7 +878,11 @@ def select_layout( String of the layout, 'even-horizontal', 'tiled', etc. Entering None (leaving this blank) is same as ``select-layout`` with no layout. In recent tmux versions, it picks the most recently - set layout. + set layout. Anything else must be a preset name below or a + layout tmux reported through :attr:`window_layout`; any other + value, such as ``"-o"`` (tmux's own *undo* flag), is refused + before reaching tmux (see ``Raises``). An explicit empty string + is also refused -- pass ``None`` to omit the layout instead. 'even-horizontal' Panes are spread out evenly from left to right across the @@ -880,8 +899,11 @@ def select_layout( 'tiled' Panes are spread out as evenly as possible over the window in both rows and columns. - 'custom' - Custom dimensions (see :term:`tmux(1)` manpages). + 'main-horizontal-mirrored', 'main-vertical-mirrored' + The main pane at the bottom or right instead. tmux 3.5+. + A saved :attr:`window_layout` + The exact arrangement it describes: tmux's classic + checksum-prefixed string, or JSON on tmux 3.8+. spread : bool, optional Spread panes out evenly (``-E`` flag). @@ -906,13 +928,45 @@ def select_layout( If tmux returns an error. ValueError If both *layout* and a flag (*spread*, *next_layout*, - *previous_layout*) are specified. + *previous_layout*) are specified, if *layout* is an explicit + empty string, or if *layout* is neither a preset name nor a + layout string tmux reports. On tmux 3.3/3.3a a layout tmux + cannot parse crashes the daemon rather than refusing cleanly + (fixed upstream in 3.4), so the value is checked before it + reaches tmux on every version. + :exc:`libtmux.exc.VersionTooLow` + If *layout* is a mirrored preset below tmux 3.5, or JSON below + tmux 3.8, which those versions would read as unparseable. + + Notes + ----- + Feeding a saved :attr:`~libtmux.Window.window_layout` back into + *layout* restores the shape exactly on every supported tmux + version, but *which pane lands in which cell* is only guaranteed + on tmux 3.8+: from that version, a plain (non-control-mode) + reader -- what every libtmux caller is, since :mod:`libtmux` + exposes no public control-mode client -- receives a JSON layout + carrying each pane's id, and restoring it puts each pane back + where it was. Before 3.8, the saved value is tmux's classic + layout string, which carries geometry only; restoring it can + rotate which pane occupies which position even though the + resulting arrangement is identical. """ flags = (spread, next_layout, previous_layout) if layout and any(flags): msg = "Cannot specify both layout and spread/next_layout/previous_layout" raise ValueError(msg) + if layout is not None and layout == "": + msg = ( + "layout must not be an empty string -- pass layout=None to " + "omit the layout (tmux then reapplies the current one)" + ) + raise ValueError(msg) + + if layout: + _require_layout_value(layout, tmux_bin=self.server.tmux_bin) + cmd = ["select-layout"] if spread: @@ -925,7 +979,11 @@ def select_layout( cmd.append("-p") if layout: # tmux allows select-layout without args - cmd.append(layout) + # "--" stops tmux's own option parsing: defense in depth for a + # layout value that reaches this point some other way (e.g. a + # future caller subclassing around the check above), so it is + # still read as the layout rather than a flag. + cmd.extend(["--", layout]) proc = self.cmd(*cmd) @@ -1722,6 +1780,30 @@ def width(self) -> str | None: """ return self.window_width + @property + def width_cells(self) -> int | None: + """Captured width in character cells, or ``None`` when unavailable. + + Reads locally. The existing :attr:`width` alias retains its raw string. + """ + return int(self.window_width) if self.window_width is not None else None + + @property + def height_cells(self) -> int | None: + """Captured height in character cells, or ``None`` when unavailable. + + Reads locally. The existing :attr:`height` alias retains its raw string. + """ + return int(self.window_height) if self.window_height is not None else None + + @property + def is_active(self) -> bool | None: + """Captured active flag within the session, or ``None`` when unavailable. + + Reads locally; zero is false and a nonzero integer is true. + """ + return bool(int(self.window_active)) if self.window_active is not None else None + # # Legacy: Redundant stuff we want to remove # @@ -1994,3 +2076,76 @@ def children(self) -> QueryList[Pane]: replacement="Window.panes property", version="0.17.0", ) + + +_LAYOUT_PRESETS = frozenset( + {"even-horizontal", "even-vertical", "main-horizontal", "main-vertical", "tiled"}, +) +_MIRRORED_LAYOUT_PRESETS = frozenset( + {"main-horizontal-mirrored", "main-vertical-mirrored"}, +) +# tmux's layout_parse reads "%hx," and requires exactly five bytes consumed; +# tmux itself always writes the checksum as four hex digits. +_CLASSIC_LAYOUT = re.compile(r"[0-9a-fA-F]{4},") + + +def _require_layout_value(layout: str, *, tmux_bin: str | None) -> None: + """Refuse a layout value tmux cannot parse, before tmux sees it. + + tmux 3.3 and 3.3a exit on a layout ``select-layout`` cannot parse, + destroying every session on the socket, and ``--`` does not help: it + turns ``-o`` from the undo flag into exactly such a value. + + tmux's own preset lookup (``layout_set_lookup``) is a prefix match, so + an unambiguous abbreviation like ``"tile"`` or ``"even-h"`` applies on + every tmux version and can never reach ``layout_parse`` (the crash path + above) -- it is accepted here too, once it resolves to exactly one + preset name among those the live tmux version actually has (mirrored + presets only exist on 3.5+, so an abbreviation ambiguous on a newer + version can be unambiguous on an older one that doesn't have them yet). + """ + if layout in _LAYOUT_PRESETS or _CLASSIC_LAYOUT.match(layout): + return + + since: str + what: str + if layout in _MIRRORED_LAYOUT_PRESETS: + since, what = "3.5", f"layout preset {layout!r}" + elif layout.startswith("{"): + since, what = "3.8", "a JSON layout" + else: + has_mirrored = has_gte_version("3.5", tmux_bin=tmux_bin) + live_presets = ( + _LAYOUT_PRESETS | _MIRRORED_LAYOUT_PRESETS + if has_mirrored + else _LAYOUT_PRESETS + ) + matches = sorted(name for name in live_presets if name.startswith(layout)) + if len(matches) > 1: + msg = ( + f"layout {layout!r} is ambiguous between {matches} -- use " + "a full preset name" + ) + raise ValueError(msg) + if len(matches) == 1: + if matches[0] in _LAYOUT_PRESETS: + return + since, what = "3.5", f"layout preset {matches[0]!r}" + else: + below_version = [] + if not has_mirrored: + below_version = sorted( + name for name in _MIRRORED_LAYOUT_PRESETS if name.startswith(layout) + ) + if len(below_version) == 1: + since, what = "3.5", f"layout preset {below_version[0]!r}" + else: + hint = " -- it looks like a tmux flag" if layout.startswith("-") else "" + msg = ( + f"layout {layout!r} is neither a preset name nor a " + f"layout string tmux reported{hint}" + ) + raise ValueError(msg) + if not has_gte_version(since, tmux_bin=tmux_bin): + msg = f"{what} needs tmux {since} or newer" + raise exc.VersionTooLow(msg) diff --git a/tests/_internal/test_query_list.py b/tests/_internal/test_query_list.py index 7d4a306c4a..cd8c2ae741 100644 --- a/tests/_internal/test_query_list.py +++ b/tests/_internal/test_query_list.py @@ -5,6 +5,7 @@ import pytest +import libtmux from libtmux._internal.query_list import ( MultipleObjectsReturned, ObjectDoesNotExist, @@ -14,6 +15,32 @@ if t.TYPE_CHECKING: from collections.abc import Callable + from typing_extensions import assert_type + + def check_public_query_types(values: libtmux.QueryList[int]) -> None: + """Public get distinguishes a required result from each default type.""" + assert_type(values.get(), int) + assert_type(values.get(lambda value: value > 0), int) + assert_type(values.get(default=None), int | None) + assert_type(values.get(default="missing"), int | str) + assert_type(values.get(None, "missing"), int | str) + assert_type(values.get(lambda value: value > 0, None), int | None) + assert_type(values.filter(lambda value: value > 0), libtmux.QueryList[int]) + + +def test_public_query_uses_existing_collection() -> None: + """The public import preserves list behavior and lookup failure contracts.""" + assert libtmux.QueryList is QueryList + values = libtmux.QueryList([1, 2, 2]) + assert values.get(1) == 1 + assert values.get(9, "missing") == "missing" + assert values.get(9, default=None) is None + assert values.filter(lambda value: value > 1) == [2, 2] + with pytest.raises(ObjectDoesNotExist): + values.get(9) + with pytest.raises(MultipleObjectsReturned): + values.get(2, default=None) + @dataclasses.dataclass class Obj: diff --git a/tests/conftest.py b/tests/conftest.py index c0015e4e65..8132e10a47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,20 @@ from __future__ import annotations +import contextlib +import os +import shlex +import signal +import typing as t + import pytest from libtmux.common import get_version, get_version_str +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Iterator + @pytest.fixture(autouse=True) def _clear_get_version_cache() -> None: @@ -20,3 +30,56 @@ def _clear_get_version_cache() -> None: """ get_version.cache_clear() get_version_str.cache_clear() + + +@pytest.fixture +def hanging_tmux(tmp_path: pathlib.Path) -> tuple[str, pathlib.Path]: + """Return a stand-in tmux that never answers, and its pid file. + + Models the failure a real fixture cannot produce cheaply: a server + that accepts a connection and never replies. ``-V`` is answered, + because a wedged SERVER does not stop the local binary reporting its + own version, and `Server.sessions` reads it on the way past. + ``exec`` preserves the recorded pid, so a test can check the process + afterwards instead of trusting that it was killed. + """ + pid_file = tmp_path / "pid" + binary = tmp_path / "tmux" + binary.write_text( + "#!/bin/sh\n" + 'if [ "$1" = "-V" ]; then echo "tmux 3.7"; exit 0; fi\n' + f"echo $$ > {shlex.quote(str(pid_file))}\n" + "exec sleep 30\n" + ) + binary.chmod(0o755) + return str(binary), pid_file + + +@pytest.fixture +def hanging_tmux_with_orphan( + tmp_path: pathlib.Path, +) -> Iterator[tuple[str, pathlib.Path, pathlib.Path]]: + """Return a stand-in tmux whose kill leaves a pipe-holding orphan behind. + + Models a failure ``hanging_tmux`` cannot: the killed process is not the + only one holding its inherited stdout/stderr pipes. A child backgrounded + and ``disown``-ed before ``exec`` survives the kill and keeps writing to + those pipes, so draining stdout/stderr for EOF after the kill blocks on + that orphan instead of completing with the caller's own deadline. + """ + pid_file = tmp_path / "pid" + orphan_pid_file = tmp_path / "orphan_pid" + binary = tmp_path / "tmux" + binary.write_text( + "#!/bin/sh\n" + 'if [ "$1" = "-V" ]; then echo "tmux 3.7"; exit 0; fi\n' + f"sleep 30 & echo $! > {shlex.quote(str(orphan_pid_file))}\n" + "disown\n" + f"echo $$ > {shlex.quote(str(pid_file))}\n" + "exec sleep 30\n" + ) + binary.chmod(0o755) + yield str(binary), pid_file, orphan_pid_file + if orphan_pid_file.exists(): + with contextlib.suppress(ProcessLookupError, ValueError): + os.kill(int(orphan_pid_file.read_text()), signal.SIGKILL) diff --git a/tests/test/test_retry.py b/tests/test/test_retry.py index c2a0f9255b..09c74964e0 100644 --- a/tests/test/test_retry.py +++ b/tests/test/test_retry.py @@ -2,104 +2,69 @@ from __future__ import annotations -from time import sleep, time +from dataclasses import dataclass import pytest from libtmux import exc -from libtmux.test.retry import retry_until +from libtmux.test import retry -def test_retry_three_times() -> None: - """Test retry_until().""" - ini = time() - value = 0 +@dataclass +class Clock: + """Advance retry time without relying on operating-system scheduling.""" - def call_me_three_times() -> bool: - nonlocal value - sleep(0.3) # Sleep for 0.3 seconds to simulate work + milliseconds: int = 0 - if value == 2: - return True + def time(self) -> float: + """Return the controlled time in seconds.""" + return self.milliseconds / 1000 - value += 1 - return False - - retry_until(call_me_three_times, 1) - - end = time() + def sleep(self, seconds: float) -> None: + """Advance time without blocking the test.""" + self.milliseconds += round(seconds * 1000) - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations +@pytest.fixture +def clock(monkeypatch: pytest.MonkeyPatch) -> Clock: + """Replace only the retry module's clock, leaving pytest's clock intact.""" + controlled = Clock() + monkeypatch.setattr(retry, "time", controlled) + return controlled -def test_function_times_out() -> None: - """Test time outs with retry_until().""" - ini = time() - - def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) - return False - with pytest.raises(exc.WaitTimeout): - retry_until(never_true, 1) +@pytest.mark.parametrize("raises", [True, False]) +def test_retry_three_times(clock: Clock, raises: bool) -> None: + """Return success after two false results and the configured intervals.""" + attempts = 0 - end = time() + def eventually_true() -> bool: + nonlocal attempts + clock.sleep(0.3) + attempts += 1 + return attempts == 3 - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + assert retry.retry_until(eventually_true, 1, raises=raises) + assert attempts == 3 + assert clock.time() == 1 -def test_function_times_out_no_raise() -> None: - """Tests retry_until() with exception raising disabled.""" - ini = time() +@pytest.mark.parametrize("raises", [True, False, None]) +def test_function_times_out(clock: Clock, raises: bool | None) -> None: + """Stop retrying at the deadline through the selected failure channel.""" + attempts = 0 def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) - return False - - retry_until(never_true, 1, raises=False) - - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations - - -def test_function_times_out_no_raise_assert() -> None: - """Tests retry_until() with exception raising disabled, returning False.""" - ini = time() - - def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) - return False - - assert not retry_until(never_true, 1, raises=False) - - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations - - -def test_retry_three_times_no_raise_assert() -> None: - """Tests retry_until() with exception raising disabled, with closure variable.""" - ini = time() - value = 0 - - def call_me_three_times() -> bool: - nonlocal value - sleep( - 0.3, - ) # Sleep for 0.3 seconds to simulate work (called 3 times in ~0.9 seconds) - - if value == 2: - return True - - value += 1 + nonlocal attempts + clock.sleep(0.1) + attempts += 1 return False - assert retry_until(call_me_three_times, 1, raises=False) + if raises: + with pytest.raises(exc.WaitTimeout): + retry.retry_until(never_true, 1, raises=raises) + else: + assert not retry.retry_until(never_true, 1, raises=raises) - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + assert attempts == 7 + assert clock.time() == 1 diff --git a/tests/test_common.py b/tests/test_common.py index 426b72d573..6e9b47a6f1 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -2,10 +2,17 @@ from __future__ import annotations +import copy import locale import logging +import os +import pickle import re +import shlex +import signal import sys +import threading +import time import typing as t import pytest @@ -29,6 +36,8 @@ ) if t.TYPE_CHECKING: + import pathlib + from libtmux.server import Server from libtmux.session import Session @@ -176,6 +185,78 @@ def test_tmux_cmd_unicode(session: Session) -> None: session.cmd("new-window", "-n", "юникод", "-F", "Ελληνικά", target=3) +@pytest.mark.parametrize("runner_name", ["run_command", "tmux_cmd"]) +def test_command_result_preserves_status_and_decoding(runner_name: str) -> None: + """A child process supplies malformed bytes and a completed nonzero exit.""" + runner = getattr(libtmux.common, runner_name) + script = ( + "import sys; " + "sys.stdout.buffer.write(b'first\\n\\nlast\\xff\\n\\n'); " + "sys.stderr.buffer.write(b'problem\\xfe\\n\\n'); " + "sys.exit(7)" + ) + result = runner("-c", script, 17, tmux_bin=sys.executable) + + assert result.cmd == [sys.executable, "-c", script, "17"] + assert result.stdout == ["first", "", "last\\xff"] + assert result.stderr == ["problem\\xfe"] + assert result.returncode == 7 + assert result.process.returncode == 7 + if runner_name == "run_command": + assert isinstance(result, libtmux.common.CommandResult) + + +@pytest.mark.parametrize("runner_name", ["run_command", "tmux_cmd"]) +def test_command_permission_failure_preserves_context( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, + runner_name: str, +) -> None: + """An unexecutable file raises the OS error with structured command context.""" + binary = tmp_path / "tmux denied" + binary.write_text("#!/bin/sh\nexit 0\n") + binary.chmod(0o600) + runner = getattr(libtmux.common, runner_name) + + with ( + caplog.at_level(logging.ERROR, logger="libtmux.common"), + pytest.raises(PermissionError) as caught, + ): + runner("list-sessions", tmux_bin=str(binary)) + + assert caught.value.filename == str(binary) + records = [r for r in caplog.records if hasattr(r, "tmux_cmd")] + assert len(records) == 1 + assert records[0].tmux_cmd == shlex.join([str(binary), "list-sessions"]) + + +def test_tmux_cmd_delegates_to_runner( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The compatibility facade delegates without starting another child.""" + result = libtmux.common.run_command("-V", tmux_bin=server.tmux_bin) + received: list[tuple[tuple[object, ...], str | None, float | None]] = [] + + def run( + *args: object, + tmux_bin: str | None = None, + timeout: float | None = None, + ) -> libtmux.common.CommandResult: + received.append((args, tmux_bin, timeout)) + return result + + monkeypatch.setattr(libtmux.common, "run_command", run) + facade = tmux_cmd("display-message", "-p", tmux_bin="custom", timeout=0.5) + + assert received == [(("display-message", "-p"), "custom", 0.5)] + assert facade.cmd is result.cmd + assert facade.stdout is result.stdout + assert facade.stderr is result.stderr + assert facade.returncode == result.returncode + assert facade.process is result.process + + class SessionCheckName(t.NamedTuple): """Test fixture for test_session_check_name().""" @@ -762,3 +843,120 @@ def test_tmux_cmd_format_separator_survives_non_utf8_locale( result = parse_output(line, "list-sessions", tmux_version) assert isinstance(result, dict) assert "session_id" in result + + +@pytest.mark.parametrize("runner_name", ["run_command", "tmux_cmd"]) +def test_tmux_cmd_timeout_kills_and_reaps( + hanging_tmux: tuple[str, pathlib.Path], + runner_name: str, +) -> None: + """An expired command leaves no tmux process behind. + + The kill is the load-bearing half. Without it a caller that gives up + only stops waiting, so repeated timeouts accumulate tmux processes + nobody is listening to. + """ + binary, pid_file = hanging_tmux + runner = getattr(libtmux.common, runner_name) + + with pytest.raises(exc.TmuxTimeout) as excinfo: + runner("list-sessions", tmux_bin=binary, timeout=0.3) + + assert excinfo.value.timeout == 0.3 + assert "list-sessions" in str(excinfo.value) + + pid = int(pid_file.read_text()) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + + +def test_tmux_timeout_round_trips_through_pickle_and_copy() -> None: + """``TmuxTimeout.args`` stays shaped like its own constructor. + + The previous ``__init__`` forwarded a pre-formatted message string to + ``Exception.__init__``, so ``self.args`` held one string while the + constructor required ``(cmd, timeout, *args)``. pickle and ``copy`` + reconstruct 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. + """ + original = exc.TmuxTimeout(["tmux", "list-sessions"], 0.3) + + # Round-trips data this process just produced, not untrusted input. + for reconstructed in ( + pickle.loads(pickle.dumps(original)), + copy.copy(original), + copy.deepcopy(original), + ): + assert isinstance(reconstructed, exc.TmuxTimeout) + assert reconstructed.cmd == original.cmd + assert reconstructed.timeout == original.timeout + assert str(reconstructed) == str(original) + + +def test_tmux_cmd_timeout_survives_orphaned_pipe_holder( + hanging_tmux_with_orphan: tuple[str, pathlib.Path, pathlib.Path], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The post-kill drain does not block on a descendant's inherited pipes. + + SIGKILL ends the timed-out process itself immediately, but an orphan + that inherited the same stdout/stderr pipes keeps them open. A bare + ``communicate()`` after ``kill()`` reads until EOF on those pipes, so + without its own bound it blocks on the orphan's lifetime rather than + completing anywhere near the caller's deadline. + """ + binary, pid_file, _orphan_pid_file = hanging_tmux_with_orphan + monkeypatch.setattr(libtmux.common, "_KILL_REAP_TIMEOUT", 0.1) + + started = time.monotonic() + with pytest.raises(exc.TmuxTimeout): + libtmux.common.run_command("list-sessions", tmux_bin=binary, timeout=0.2) + elapsed = time.monotonic() - started + + # Bounded by timeout + the (patched) reap grace, not the orphan's sleep. + assert elapsed < 1.0 + + pid = int(pid_file.read_text()) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + + +def test_tmux_cmd_without_timeout_still_waits( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """The bound is opt-in; omitting it keeps the historical behaviour. + + The previous body passed ``timeout=0.3`` and asserted + :exc:`~libtmux.exc.TmuxTimeout`, which exercises the *bounded* path, + not the ``None`` case this test's name and docstring claim -- it + would pass identically whether or not a bare ``timeout=None`` call + ever waited at all. Runs the call on a thread bounded well under the + stub's 30s sleep: still running after that bound means it did not + raise early, and killing the stub directly lets the thread return + without this test itself waiting anywhere near 30s. + """ + binary, pid_file = hanging_tmux + outcome: list[tmux_cmd | BaseException] = [] + + def call() -> None: + try: + outcome.append(tmux_cmd("list-sessions", tmux_bin=binary)) + except BaseException as e: # noqa: BLE001 + outcome.append(e) + + thread = threading.Thread(target=call, daemon=True) + thread.start() + thread.join(timeout=0.3) + assert thread.is_alive(), "a bare `timeout=None` call must still be waiting" + + # Unblock the thread by killing the stub directly, not through + # libtmux's own timeout/kill path -- that is what this test verifies + # was never invoked. + os.kill(int(pid_file.read_text()), signal.SIGKILL) + thread.join(timeout=5) + assert not thread.is_alive() + + assert len(outcome) == 1 + assert not isinstance(outcome[0], exc.TmuxTimeout) diff --git a/tests/test_control_mode.py b/tests/test_control_mode.py index f72f68466b..0879094481 100644 --- a/tests/test_control_mode.py +++ b/tests/test_control_mode.py @@ -4,12 +4,14 @@ import locale import os -import select +import signal import sys +import time import typing as t import pytest +from libtmux._internal import control_mode as control_module from libtmux._internal.control_mode import ControlMode from libtmux.formats import FORMAT_SEPARATOR @@ -28,17 +30,73 @@ def test_control_mode_creates_client( assert ctl.client_name != "" +@pytest.mark.parametrize("stop_client", [False, True], ids=["normal", "stopped"]) def test_control_mode_cleanup( control_mode: t.Callable[[], ControlMode], server: Server, + stop_client: bool, ) -> None: - """Client is removed after ControlMode context exits.""" - with control_mode(): + """Exiting releases the client and its streams. + + For the stopped client, cleanup must also be *prompt*: SIGCONT wakes a + SIGSTOP'd process so the pending SIGTERM is seen immediately. Without + it, ``_stop()`` still cleans up correctly -- ``wait(timeout=5)`` expires + and the ``kill()`` fallback reaps the process -- but only after 5s, + which is the production hang this test exists to catch. Bounding + elapsed time well under that fallback makes a dropped SIGCONT fail the + test instead of only slowing it down. The clock starts just before the + ``with`` block exits, so it times only ``__exit__``/``_stop()`` -- not + spawn or registration, which are unrelated to the SIGCONT path and + would otherwise eat into the margin under load. + """ + with control_mode() as ctl: assert len(server.list_clients()) > 0 - - # After context exit, client should be gone + if stop_client: + os.kill(ctl._proc.pid, signal.SIGSTOP) + _, state = os.waitpid(ctl._proc.pid, os.WUNTRACED) + assert os.WIFSTOPPED(state) + started = time.monotonic() + elapsed = time.monotonic() - started + + assert ctl.stdout.closed + assert ctl._proc.stderr is not None and ctl._proc.stderr.closed + assert ctl._proc.poll() is not None clients = server.list_clients() assert len(clients) == 0 + assert elapsed < 2, ( + f"cleanup took {elapsed:.2f}s; a stopped client should be woken by " + "SIGCONT and not fall through to the 5s wait() timeout" + ) + + +@pytest.mark.parametrize("problem", [RuntimeError, KeyboardInterrupt]) +def test_control_mode_failed_registration_closes_streams( + control_mode: t.Callable[[], ControlMode], + monkeypatch: pytest.MonkeyPatch, + problem: type[BaseException], +) -> None: + """A failed handshake must release the real subprocess and its pipes.""" + + def reject_registration(*args: object, **kwargs: object) -> None: + message = "registration failed" + raise problem(message) + + monkeypatch.setattr(control_module, "retry_until", reject_registration) + ctl = control_mode() + try: + with pytest.raises(problem, match="registration failed"), ctl: + pytest.fail("Registration must fail before entering the body") + assert ctl.stdout.closed + assert ctl._proc.stderr is not None and ctl._proc.stderr.closed + assert ctl._proc.poll() is not None + finally: + if ctl._proc.poll() is None: + os.close(ctl._write_fd) + ctl._proc.kill() + ctl._proc.wait(timeout=5) + ctl.stdout.close() + if ctl._proc.stderr is not None: + ctl._proc.stderr.close() def test_control_mode_client_name( @@ -83,17 +141,10 @@ def test_control_mode_stdout_preserves_non_ascii_output( with control_mode() as ctl: os.write( ctl._write_fd, - f"display-message -p '{FORMAT_SEPARATOR}'\n".encode(), + f"display-message -p '{FORMAT_SEPARATOR}'\ndetach-client\n".encode(), ) - - for _ in range(20): - ready, _, _ = select.select([ctl.stdout], [], [], 1) - assert ready, "timed out waiting for control-mode output" - - line = ctl.stdout.readline() - if FORMAT_SEPARATOR in line: - break - else: - pytest.fail("FORMAT_SEPARATOR U+241E not found in control output") + stdout, stderr = ctl._proc.communicate(timeout=5) + assert ctl._proc.returncode == 0, stderr + assert FORMAT_SEPARATOR in stdout finally: locale.setlocale(locale.LC_CTYPE, old_lc_ctype) diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000000..cb51546fb3 --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,86 @@ +"""Execute every script under examples/ end to end, as a reader would. + +Each script is a standalone program (not a doctest, not a fixture): it owns +its own :class:`~libtmux.Server` and spawns real tmux sessions, so it is run +here as a subprocess with ``sys.executable``, exactly as +``python examples/quickstart.py`` reads on the page. This is what proves an +example is runnable as shown rather than merely present in the tree. +""" + +from __future__ import annotations + +import pathlib +import subprocess +import sys + +import pytest + +EXAMPLES_DIR = pathlib.Path(__file__).parent.parent / "examples" +EXAMPLE_SCRIPTS = sorted(EXAMPLES_DIR.glob("*.py")) + + +@pytest.mark.examples +@pytest.mark.parametrize( + "script", + EXAMPLE_SCRIPTS, + ids=[script.stem for script in EXAMPLE_SCRIPTS], +) +def test_example_runs_cleanly(script: pathlib.Path) -> None: + """Run one examples/*.py script and require a clean exit. + + Spawns real tmux servers via the script's own ``Server.owned()`` calls, + so this belongs with the rest of the suite -- every other test here + already requires a live tmux binary on ``PATH``. + """ + result = subprocess.run( + [sys.executable, str(script)], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, ( + f"{script.name} exited {result.returncode}\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + + +def test_examples_directory_is_not_empty() -> None: + """Guard against a silently emptied examples/ directory. + + A future cleanup that deletes every script would leave the parametrized + test above collecting zero cases -- a green run reporting nothing wrong. + """ + assert len(EXAMPLE_SCRIPTS) > 0 + + +def test_command_results_example_never_probes_the_default_socket() -> None: + """command_results.py's probe never reaches a reader's default socket. + + Regression for PY-10: the probe used to call ``run_command()`` with no + socket selector, so it silently fell through to + ``$TMUX_TMPDIR/tmux-/default`` -- the reader's own interactive + tmux, if one happens to be running. It is read-only and ``has-session`` + never starts a server, so this was harmless, but it contradicted the + stated property that examples never touch a session the reader already + has open, and its printed output changed depending on whether the + reader happened to be running tmux. Assert the probe's own isolated + socket name appears in stdout and the ambient default socket path does + not. + """ + script = EXAMPLES_DIR / "command_results.py" + result = subprocess.run( + [sys.executable, str(script)], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0 + assert "libtmux-examples-command-results-no-such-server" in result.stdout + # The ambient default socket name tmux falls back to with no selector + # (`$TMUX_TMPDIR/tmux-/default`) must never appear. + assert "/default" not in result.stdout diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 4e03f0a26b..77e4d7e846 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -6,6 +6,7 @@ import pytest +from libtmux import exc from libtmux._internal.constants import Hooks from libtmux._internal.sparse_array import SparseArray from libtmux.common import has_gte_version @@ -315,6 +316,7 @@ class HookTestCase(t.NamedTuple): hook: str # tmux hook name (hyphenated) min_version: str = "3.0" # Minimum tmux version required xfail_reason: str | None = None # Mark as expected failure with reason + removed_version: str | None = None # --- Alert Hooks --- @@ -381,7 +383,7 @@ class HookTestCase(t.NamedTuple): HookTestCase("after_new_window", "after-new-window"), HookTestCase("after_paste_buffer", "after-paste-buffer"), HookTestCase("after_pipe_pane", "after-pipe-pane"), - HookTestCase("after_queue", "after-queue"), + HookTestCase("after_queue", "after-queue", removed_version="3.8"), HookTestCase("after_refresh_client", "after-refresh-client"), HookTestCase("after_rename_session", "after-rename-session"), HookTestCase("after_rename_window", "after-rename-window"), @@ -416,7 +418,39 @@ class HookTestCase(t.NamedTuple): # Combine all hook test cases ALL_HOOK_TEST_CASES: list[HookTestCase] = ( - ALERT_HOOKS + CLIENT_HOOKS + SESSION_HOOKS + WINDOW_HOOKS + PANE_HOOKS + AFTER_HOOKS + ALERT_HOOKS + + CLIENT_HOOKS + + SESSION_HOOKS + + WINDOW_HOOKS + + PANE_HOOKS + + AFTER_HOOKS + + [ + HookTestCase(name.replace("-", "_"), name, "3.8") + for name in ( + "after-swap-window", + "client-closed", + "client-created", + "marked-pane-changed", + "pane-activity", + "pane-bell", + "pane-command-finished", + "pane-command-started", + "pane-created", + "pane-mode-entered", + "pane-mode-exited", + "pane-moved", + "pane-prompt-closed", + "pane-prompt-opened", + "pane-resized", + "pane-shell-prompt", + "session-added-to-group", + "session-removed-from-group", + "window-closed", + "window-created", + "window-unzoomed", + "window-zoomed", + ) + ] ) @@ -451,6 +485,11 @@ def test_hook_set_show_unset_cycle(server: Server, test_case: HookTestCase) -> N hook_cmd = "display-message 'test hook fired'" + if test_case.removed_version and has_gte_version(test_case.removed_version): + with pytest.raises(exc.InvalidOption, match="invalid option"): + session.set_hook(f"{test_case.hook}[0]", hook_cmd) + return + # Test set_hook (using session-level hook which works on all tmux versions) session.set_hook(f"{test_case.hook}[0]", hook_cmd) diff --git a/tests/test_neo.py b/tests/test_neo.py index f67215e57b..d5bd2b23a1 100644 --- a/tests/test_neo.py +++ b/tests/test_neo.py @@ -13,12 +13,15 @@ import pytest +from libtmux import exc +from libtmux.formats import FORMAT_SEPARATOR from libtmux.neo import ( _CONTEXT_ONLY_TOKENS, FIELD_VERSION, SCOPES_BY_LIST_CMD, Obj, _is_target_not_found_error, + _split_records, _token_scope, get_output_format, ) @@ -258,3 +261,76 @@ def test_every_obj_field_classifies_to_known_scope() -> None: "(add them to _SCOPE_OVERRIDES, _SCOPE_PREFIXES, " f"_UNIVERSAL_TOKENS, or _CONTEXT_ONLY_TOKENS): {unclassified}" ) + + +class SplitRecordsFixture(t.NamedTuple): + """Test fixture for :func:`_split_records`.""" + + test_id: str + values: list[list[str]] + + +SPLIT_RECORDS_FIXTURES: list[SplitRecordsFixture] = [ + SplitRecordsFixture("single_clean_record", [["a", "b", "c"]]), + SplitRecordsFixture("two_clean_records", [["a", "b", "c"], ["d", "e", "f"]]), + SplitRecordsFixture("newline_in_first_field", [["a\nx", "b", "c"]]), + SplitRecordsFixture("newline_in_middle_field", [["a", "b\nx", "c"]]), + SplitRecordsFixture("newline_in_last_field", [["a", "b", "c\nx"]]), + SplitRecordsFixture("consecutive_newlines", [["a", "b\n\n\nx", "c"]]), + SplitRecordsFixture( + "poisoned_record_between_clean_ones", + [["a", "b", "c"], ["d", "e\npath", "f"], ["g", "h", "i"]], + ), + SplitRecordsFixture("empty_values", [["", "", ""]]), +] + + +@pytest.mark.parametrize( + SplitRecordsFixture._fields, + SPLIT_RECORDS_FIXTURES, + ids=[fixture.test_id for fixture in SPLIT_RECORDS_FIXTURES], +) +def test_split_records_round_trips_newlines( + test_id: str, + values: list[list[str]], +) -> None: + """A newline inside a value must not split its record. + + tmux emits one record per line, so a value containing a newline -- + ``pane_current_path`` under a directory whose name has one -- used + to arrive as two short fragments and fail ``parse_output``'s strict + ``zip``. Because every pane row carries ``pane_current_path``, that + broke enumeration for the whole server, not just the one pane. + """ + assert test_id + field_count = len(values[0]) + # Rebuild exactly what tmux writes: each record's fields, every one + # terminated by the separator, and records terminated by newlines. + stdout_text = "".join( + "".join(f"{value}{FORMAT_SEPARATOR}" for value in record) + "\n" + for record in values + ) + stdout = stdout_text.split("\n") + while stdout and stdout[-1] == "": + stdout.pop() + + records = _split_records(stdout, field_count) + + assert len(records) == len(values) + for record, expected in zip(records, values, strict=True): + parsed = record.split(FORMAT_SEPARATOR)[:-1] + assert parsed == expected + + +@pytest.mark.parametrize("trailer", ["", FORMAT_SEPARATOR]) +def test_split_records_reports_a_forged_separator(trailer: str) -> None: + """Malformed field counts fail even if the last delimiter is missing.""" + stdout = [f"a{FORMAT_SEPARATOR}b{FORMAT_SEPARATOR}c{trailer}"] + + with pytest.raises(exc.LibTmuxException, match="could not be parsed"): + _split_records(stdout, 2) + + +def test_split_records_handles_no_objects() -> None: + """An empty listing yields no records rather than a bogus one.""" + assert _split_records([], 5) == [] diff --git a/tests/test_pane.py b/tests/test_pane.py index 416032ce62..033c298fa7 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -2,26 +2,191 @@ from __future__ import annotations +import contextlib +import fcntl import logging +import os import pathlib +import select import shutil +import struct +import subprocess +import termios +import threading import typing as t import pytest -from libtmux import exc +from libtmux import Pane, Server, exc from libtmux.common import has_gte_version from libtmux.constants import PaneDirection, ResizeAdjustmentDirection from libtmux.test.retry import retry_until if t.TYPE_CHECKING: from libtmux._internal.types import StrPath - from libtmux.pane import Pane from libtmux.session import Session logger = logging.getLogger(__name__) +@pytest.mark.parametrize("raw", [None, "0", "1"]) +def test_decoded_pane_fields_are_local(raw: str | None) -> None: + """Decoded fields preserve absence and zero without executing tmux.""" + pane = Pane( + server=Server(tmux_bin="missing-decoded-fields-tmux"), + pane_width="80", + pane_height="24", + pane_left="5", + pane_top="10", + pane_active=raw, + pane_dead=raw, + ) + assert pane.width_cells == 80 + assert pane.height_cells == 24 + assert pane.left_cells == 5 + assert pane.top_cells == 10 + assert pane.width == "80" + assert pane.height == "24" + assert pane.is_active is (None if raw is None else raw == "1") + assert pane.is_dead is (None if raw is None else raw == "1") + pane.pane_width = None + pane.pane_height = None + pane.pane_left = None + pane.pane_top = None + assert pane.width_cells is None + assert pane.height_cells is None + assert pane.left_cells is None + assert pane.top_cells is None + + +def test_decoded_pane_fields_match_live_capture(session: Session) -> None: + """Active and inactive panes retain distinct typed captured flags.""" + window = session.active_window + window.split(attach=False) + panes = window.panes + assert len(panes) == 2 + assert sum(pane.is_active is True for pane in panes) == 1 + assert all(pane.is_dead is False for pane in panes) + assert all(isinstance(pane.width_cells, int) for pane in panes) + assert all(isinstance(pane.left_cells, int) for pane in panes) + assert all(isinstance(pane.top_cells, int) for pane in panes) + # The split created a second pane below the first: same left edge, + # a top edge strictly greater than the pane above it. + top, bottom = sorted(panes, key=lambda p: t.cast("int", p.top_cells)) + assert top.left_cells == bottom.left_cells + assert bottom.top_cells is not None + assert top.top_cells is not None + assert bottom.top_cells > top.top_cells + + +def test_dead_pane_pid_has_no_numeric_coercion(session: Session) -> None: + """A dead pane's ``#{pane_pid}`` never breaks a refresh. + + tmux 3.8 changed ``#{pane_pid}`` from ``"0"`` to an empty string for a + pane whose process has already exited (libtmux-java crashed on exactly + this). libtmux stores ``pane_pid`` as ``str | None`` and never calls + ``int()`` on it, so every shape must round-trip through a live + ``refresh()`` without raising: a numeric pid, or -- since PY2-8, where + ``refresh()`` learned to clear a field tmux now reports empty rather + than keep its last value -- ``None`` on tmux 3.8+. + """ + window = session.new_window(window_name="dead_pane_pid") + pane = window.active_pane + assert pane is not None + pane.cmd("set-option", "-p", "remain-on-exit", "on") + pane.send_keys("exit", enter=True) + + def _pane_is_dead() -> bool: + pane.refresh() + return pane.pane_dead == "1" + + retry_until(_pane_is_dead, 3, raises=True) + + assert pane.pane_pid is None or (pane.pane_pid or "").isdigit() + + +def test_refresh_clears_a_field_that_became_empty(session: Session) -> None: + """refresh() clears a field tmux now reports empty. + + Regression for PY2-8: the row parser drops empty values, and + ``Obj._refresh`` used to ``setattr`` only the keys present in that + filtered row, so a field that went from set to empty -- a title + cleared with ``select-pane -T ''`` after a real one -- kept its last + non-empty value forever, disagreeing with what a fresh query for the + same pane reports. + """ + pane = session.active_pane + assert pane is not None + + pane.set_title("abc") + assert pane.pane_title == "abc" + + pane.set_title("") + assert pane.pane_title is None + + fresh = session.server.panes.get(pane_id=pane.pane_id) + assert fresh is not None + assert fresh.pane_title is None + + +def test_send_keys_and_capture_pane_raise_on_a_killed_pane(session: Session) -> None: + """send_keys() and capture_pane() raise on a stale, killed-pane handle. + + Regression for PY-5. Before this fix, ``send_keys`` returned ``None`` + and ``capture_pane`` returned ``[]`` for the exact same target that + ``refresh()`` already reports as gone (``TmuxObjectDoesNotExist``) -- + keystrokes went nowhere with no indication, and an empty capture was + indistinguishable from a blank pane. Raw tmux for the same target + exits 1 with ``can't find pane: ...`` (D2/D3). + + ``reset()`` is checked alongside them: regression for PY2-1, where it + discarded its ``send-keys``/``clear-history`` command's result and + returned the ``Pane`` unchanged instead of raising like every other + typed method here. + """ + window = session.active_window + dead = window.split(attach=False) + dead.kill() + + with pytest.raises(exc.LibTmuxException, match="can't find pane"): + dead.send_keys("echo unreachable") + + with pytest.raises(exc.LibTmuxException, match="can't find pane"): + dead.capture_pane() + + with pytest.raises(exc.LibTmuxException, match="can't find pane"): + dead.enter() + + with pytest.raises(exc.LibTmuxException, match="can't find pane"): + dead.reset() + + with pytest.raises(exc.TmuxObjectDoesNotExist): + dead.refresh() + + +def test_split_raises_tmuxs_own_error_on_a_killed_pane(session: Session) -> None: + """split() raises tmux's own short error, not a dump of the pane. + + Regression for PY2-2: the failure path built + ``LibTmuxException(stderr, self.__dict__, self.window.panes)``, so + ``str(exc)`` embedded every field of the dead pane and its live + siblings -- thousands of characters for what raw tmux reports as one + line, ``split-window: can't find pane: ...``. Every other typed + method on a killed pane already raises that shape (D9). + """ + window = session.active_window + dead = window.split(attach=False) + dead.kill() + + with pytest.raises( + exc.LibTmuxException, + match=r"^split-window: can't find pane", + ) as exc_info: + dead.split() + + assert "pane_active" not in str(exc_info.value) + + def test_send_keys(session: Session) -> None: """Verify Pane.send_keys().""" pane = session.active_window.active_pane @@ -79,6 +244,7 @@ def test_capture_pane(session: Session) -> None: ) pane = session.active_window.active_pane assert pane is not None + retry_until(lambda: pane.capture_pane() == ["$"], 1, raises=True) pane_contents = "\n".join(pane.capture_pane()) assert pane_contents == "$" pane.send_keys( @@ -86,6 +252,14 @@ def test_capture_pane(session: Session) -> None: literal=True, suppress_history=False, ) + retry_until( + lambda: ( + pane.capture_pane() + == [r'$ printf "\n%s\n" "Hello World !"', "", "Hello World !", "$"] + ), + 1, + raises=True, + ) pane_contents = "\n".join(pane.capture_pane()) assert pane_contents == r'$ printf "\n%s\n" "Hello World !"{}'.format( "\n\nHello World !\n$", @@ -104,9 +278,15 @@ def test_capture_pane_start(session: Session) -> None: ) pane = session.active_window.active_pane assert pane is not None + retry_until(lambda: pane.capture_pane() == ["$"], 1, raises=True) pane_contents = "\n".join(pane.capture_pane()) assert pane_contents == "$" pane.send_keys(r'printf "%s"', literal=True, suppress_history=False) + retry_until( + lambda: pane.capture_pane() == ['$ printf "%s"', "$"], + 1, + raises=True, + ) pane_contents = "\n".join(pane.capture_pane()) assert pane_contents == '$ printf "%s"\n$' pane.send_keys("clear -x", literal=True, suppress_history=False) @@ -149,9 +329,15 @@ def test_capture_pane_end(session: Session) -> None: ) pane = session.active_window.active_pane assert pane is not None + retry_until(lambda: pane.capture_pane() == ["$"], 1, raises=True) pane_contents = "\n".join(pane.capture_pane()) assert pane_contents == "$" pane.send_keys(r'printf "%s"', literal=True, suppress_history=False) + retry_until( + lambda: pane.capture_pane() == ['$ printf "%s"', "$"], + 1, + raises=True, + ) pane_contents = "\n".join(pane.capture_pane()) assert pane_contents == '$ printf "%s"\n$' pane_contents = "\n".join(pane.capture_pane(end=0)) @@ -1082,6 +1268,65 @@ class DisplayPopupCase(t.NamedTuple): ] +@pytest.fixture +def terminal_client(server: Server, session: Session) -> t.Iterator[str]: + """Attach a terminal client for popup execution.""" + socket_path = server.cmd("display-message", "-p", "#{socket_path}").stdout[0] + with contextlib.ExitStack() as cleanup: + master, slave = os.openpty() + cleanup.callback(os.close, master) + cleanup.callback(os.close, slave) + stop = threading.Event() + client: subprocess.Popen[bytes] | None = None + + def drain() -> None: + while not stop.is_set(): + if select.select([master], [], [], 0.1)[0]: + try: + if not os.read(master, 65536): + return + except OSError: + return + + try: + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 100, 0, 0)) + client = subprocess.Popen( + [ + server.tmux_bin or "tmux", + "-S", + socket_path, + "attach-session", + "-t", + str(session.session_id), + ], + stdin=slave, + stdout=slave, + stderr=slave, + env={**os.environ, "TERM": "xterm-256color"}, + start_new_session=True, + ) + reader = threading.Thread(target=drain, daemon=True) + reader.start() + cleanup.callback(reader.join, timeout=1) + cleanup.callback(stop.set) + client_name = os.ttyname(slave) + + def attached() -> bool: + clients = server.cmd("list-clients", "-F", "#{client_name}").stdout + return client_name in clients + + retry_until(attached, 3, raises=True) + yield client_name + finally: + if client is not None: + client.terminate() + try: + client.wait(timeout=3) + except subprocess.TimeoutExpired: + client.kill() + client.wait(timeout=3) + + @pytest.mark.parametrize( list(DisplayPopupCase._fields), DISPLAY_POPUP_CASES, @@ -1091,7 +1336,7 @@ def test_display_popup_flags( test_id: str, kwargs: dict[str, t.Any], min_tmux_version: str | None, - control_mode: t.Callable[..., t.Any], + terminal_client: str, session: Session, tmp_path: pathlib.Path, ) -> None: @@ -1110,14 +1355,13 @@ def test_display_popup_flags( call_kwargs = {"command": f"touch {marker}", "close_on_exit": True, **kwargs} - with control_mode(): - pane.display_popup(**call_kwargs) + pane.display_popup(**call_kwargs) retry_until(lambda: marker.exists(), 3, raises=True) def test_display_popup_close_on_success( - control_mode: t.Callable[..., t.Any], + terminal_client: str, session: Session, tmp_path: pathlib.Path, ) -> None: @@ -1126,8 +1370,7 @@ def test_display_popup_close_on_success( pane = session.active_window.active_pane assert pane is not None - with control_mode(): - pane.display_popup(command=f"touch {marker}", close_on_success=True) + pane.display_popup(command=f"touch {marker}", close_on_success=True) retry_until(lambda: marker.exists(), 3, raises=True) @@ -1157,26 +1400,20 @@ def test_display_popup_close_existing( def test_display_popup_target_client( - control_mode: t.Callable[..., t.Any], + terminal_client: str, session: Session, tmp_path: pathlib.Path, ) -> None: - """Test Pane.display_popup(target_client=...) emits ``-c ``. - - ``-c`` has been on ``display-popup`` since tmux 3.2a, so no version - guard is needed. The popup itself is invisible without a TTY-backed - client; this is a smoke test for the flag-passing path. - """ + """Run the popup command on the specified terminal client.""" pane = session.active_window.active_pane assert pane is not None marker = tmp_path / "popup_target_client.marker" - with control_mode() as ctl: - pane.display_popup( - command=f"touch {marker}", - close_on_exit=True, - target_client=ctl.client_name, - ) + pane.display_popup( + command=f"touch {marker}", + close_on_exit=True, + target_client=terminal_client, + ) retry_until(lambda: marker.exists(), 3, raises=True) @@ -1812,8 +2049,11 @@ def test_new_pane_floating(session: Session) -> None: if has_gte_version("3.7"): floating = pane.new_pane(width=80, height=15, x=5, y=3, shell="sleep 30") assert floating.pane_floating_flag == "1" - assert floating.pane_width == "80" - assert floating.pane_height == "15" + border = 1 if has_gte_version("3.8") else 0 + assert floating.pane_width == str(80 - 2 * border) + assert floating.pane_height == str(15 - 2 * border) + assert floating.pane_x == str(5 + border) + assert floating.pane_y == str(3 + border) else: with pytest.raises(exc.LibTmuxException, match=r"new_pane .*requires tmux 3.7"): pane.new_pane(width=40, height=10) diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 5111a06706..1d30be2a34 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -12,6 +12,7 @@ import pytest +from libtmux import exc from libtmux.common import has_gte_version from libtmux.test.retry import retry_until @@ -353,13 +354,14 @@ def prompt_ready() -> bool: # Send command with a unique marker to detect completion marker = f"__DONE_{test_id}__" - full_command = f'{command}; echo "{marker}"' + full_command = f'{command}; printf "\\n%s\\n" "{marker}"' pane.send_keys(full_command, literal=False, suppress_history=False) - # Wait for marker to appear + # The echoed command contains the marker before its output arrives. def command_complete() -> bool: - output = "\n".join(pane.capture_pane()) - return marker in output + return any( + line.rstrip(" ") == marker for line in pane.capture_pane(join_wrapped=True) + ) retry_until(command_complete, 5, raises=True) @@ -478,10 +480,10 @@ def prompt_ready() -> bool: ("kwargs", "min_tmux_version"), [ ({"quiet": True}, None), - ({"alternate_screen": True}, None), + ({"alternate_screen": True, "quiet": True}, None), ({"mode_screen": True}, "3.6"), ], - ids=["quiet", "alternate_screen", "mode_screen_v36"], + ids=["quiet", "alternate_screen_quiet", "mode_screen_v36"], ) def test_capture_pane_flag_smoke( kwargs: dict[str, t.Any], @@ -496,6 +498,12 @@ def test_capture_pane_flag_smoke( state that's awkward to drive headless; assert that the call returns a list without raising. Output-pattern assertions live in CAPTURE_PANE_CASES for the flags whose behaviour is observable. + + ``alternate_screen`` is paired with ``quiet`` here: an ordinary pane + is never in the alternate screen, so tmux's own ``-a`` fails + (``no alternate screen``) unless ``-q`` suppresses it -- see + ``test_capture_pane_alternate_screen_without_quiet_raises`` for that + failure surfaced. """ if min_tmux_version and not has_gte_version(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") @@ -507,6 +515,20 @@ def test_capture_pane_flag_smoke( assert isinstance(result, list) +def test_capture_pane_alternate_screen_without_quiet_raises(session: Session) -> None: + """capture_pane(alternate_screen=True) raises off the alternate screen. + + Regression for PY-5: a typed method's tmux failure must surface + (D2), carrying tmux's own stderr (D3), rather than returning an + empty/partial result indistinguishable from "nothing captured". + """ + pane = session.active_window.active_pane + assert pane is not None + + with pytest.raises(exc.LibTmuxException, match="no alternate screen"): + pane.capture_pane(alternate_screen=True) + + def test_capture_pane_to_buffer(session: Session) -> None: """Test capture_pane(to_buffer=...) writes to a tmux buffer.""" pane = session.active_window.active_pane diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 23acbcfe92..b54bcb8451 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -5,6 +5,8 @@ import contextlib import os import pathlib +import subprocess +import sys import textwrap import time import typing as t @@ -15,6 +17,8 @@ if t.TYPE_CHECKING: import pytest +REPO_ROOT = pathlib.Path(__file__).parent.parent + def test_plugin( pytester: pytest.Pytester, @@ -221,3 +225,63 @@ def test_reap_test_server_tolerates_none() -> None: other nullable paths in the API. """ _reap_test_server(None) + + +def test_pytest_benchmarks_directly_raises_usage_error() -> None: + """``pytest benchmarks/`` errors instead of silently collecting nothing. + + Regression for PY-11. Runs the real command against this checkout + (not a ``pytester`` sandbox, so it exercises the actual root + ``conftest.py`` hook): pytest's default ``python_files`` + (``test_*.py``) never matches ``benchmarks/``'s ``bench_*.py`` files, + which used to collect 0 items and exit 0 -- indistinguishable from + "nothing to benchmark". It now exits with pytest's own usage-error + code and names ``just bench``. + """ + result = subprocess.run( + [sys.executable, "-m", "pytest", "benchmarks/"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 4 # pytest.ExitCode.USAGE_ERROR + assert "collected 0 items" in result.stdout + result.stderr + assert "just bench" in result.stdout + result.stderr + + +def test_just_bench_still_collects_and_runs() -> None: + """The documented workaround (``-o python_files``) is unaffected. + + Companion to the error-path test above: the collection guard must + not fire, and must not otherwise interfere, once there is something + to collect. ``--collect-only`` proves discovery without running the + benchmarks here; ``--benchmark-only`` is deliberately omitted -- this + test itself may run under this suite's own ``-n auto`` (see + CONTRIBUTING.md's coverage invocation), and the spawned subprocess + inherits that xdist worker's environment, which makes pytest-benchmark + auto-activate ``--benchmark-disable`` and then refuse to run alongside + an explicit ``--benchmark-only``. + """ + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "benchmarks/", + "-o", + "python_files=bench_*.py", + "--co", # collect-only: prove discovery, skip running them here + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "no tests ran" not in result.stdout + assert "bench_" in result.stdout diff --git a/tests/test_readme.py b/tests/test_readme.py new file mode 100644 index 0000000000..4a4b5eaaab --- /dev/null +++ b/tests/test_readme.py @@ -0,0 +1,71 @@ +"""Prove a README snippet is runnable as published, not only inside pytest. + +``README.md``'s ``>>> `` blocks are doctests, collected and run by the same +suite as everything else (see ``.github/WRITING.md``). That proves the +snippet executes *inside* this suite, where ``conftest.py``'s +``add_doctest_fixtures`` injects names like ``Server`` into the doctest +namespace -- it does not prove the snippet a reader copies into a plain +``python`` shell works, since that injection isn't there. This module +extracts a block straight from the file and runs it with a bare +interpreter to check the second claim too. +""" + +from __future__ import annotations + +import pathlib +import subprocess +import sys + +README = pathlib.Path(__file__).parent.parent / "README.md" + + +def _extract_prompted_block(heading: str) -> str: + """Return the runnable source of the first fenced block after *heading*. + + Reads the block straight from ``README.md`` rather than duplicating it + here, so this cannot silently drift from what a reader actually sees. + Only ``>>> ``/``... `` prompted lines are source; any other line inside + the fence is a doctest's expected output, not code to run. + """ + text = README.read_text() + start = text.index(heading) + fence_start = text.index("```python", start) + fence_end = text.index("```", fence_start + len("```python")) + block = text[fence_start:fence_end].splitlines()[1:] # drop the fence line + + source_lines = [] + for line in block: + if line.startswith(">>> "): + source_lines.append(line[len(">>> ") :]) + elif line.startswith("... "): + source_lines.append(line[len("... ") :]) + return "\n".join(source_lines) + + +def test_run_any_tmux_command_snippet_runs_without_doctest_fixtures() -> None: + """The "Run any tmux command" snippet runs standalone. + + Regression for PY2-5: the published block called a bare ``Server(...)`` + that only resolves during this suite's own doctest run, where + ``conftest.py`` rebinds ``Server`` to a test factory -- copied into a + fresh interpreter, it raised ``NameError: name 'Server' is not + defined``. Run here with plain ``python -c``, outside pytest and its + injected doctest namespace entirely. + """ + source = _extract_prompted_block("### Run any tmux command") + assert source, "could not extract the snippet from README.md" + + result = subprocess.run( + [sys.executable, "-c", source], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, ( + "README's 'Run any tmux command' snippet failed outside the " + f"doctest suite\nsource:\n{source}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + assert "NameError" not in result.stderr diff --git a/tests/test_server.py b/tests/test_server.py index 6175a7f9ad..fda1b5b053 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,20 +2,26 @@ from __future__ import annotations +import contextlib import functools import logging import os import pathlib +import shlex import shutil +import signal import subprocess +import sys +import threading import time import typing as t import pytest -from libtmux import exc +from libtmux import common, exc from libtmux._internal.control_mode import ControlMode from libtmux.server import Server +from libtmux.test.retry import retry_until if t.TYPE_CHECKING: from libtmux._internal.types import StrPath @@ -285,6 +291,115 @@ def test_raise_if_dead_does_not_raise_if_alive(server: Server) -> None: server.raise_if_dead() +def test_is_alive_propagates_timeout( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """A wedged server is not reported ``False`` -- it is unknown, not dead. + + A bare ``except Exception: return False`` would swallow + :exc:`~libtmux.exc.TmuxTimeout` into "dead", which is wrong for a + server that is merely slow to answer; a caller told "dead" may start + a second server alongside one that is still there. Bounded by + ``Server.timeout`` rather than the stub's full 30s sleep. + """ + binary, _pid_file = hanging_tmux + wedged = Server(tmux_bin=binary, timeout=0.2) + + started = time.monotonic() + with pytest.raises(exc.TmuxTimeout): + wedged.is_alive() + assert time.monotonic() - started < 5 + + +def test_raise_if_dead_propagates_timeout( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """``raise_if_dead`` honors ``Server.timeout`` instead of blocking. + + It used to run ``subprocess.check_call`` directly, bypassing + ``Server.cmd`` and the server-wide timeout entirely, so this could + block indefinitely against a wedged server. Bounded here by + ``Server.timeout`` rather than the stub's full 30s sleep. + """ + binary, _pid_file = hanging_tmux + wedged = Server(tmux_bin=binary, timeout=0.2) + + started = time.monotonic() + with pytest.raises(exc.TmuxTimeout): + wedged.raise_if_dead() + assert time.monotonic() - started < 5 + + +def test_cmd_timeout_falls_back_to_server_default( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """Omitting ``timeout`` on ``Server.cmd`` uses the server's own bound.""" + binary, _pid_file = hanging_tmux + bounded = Server(tmux_bin=binary, timeout=0.2) + + with pytest.raises(exc.TmuxTimeout): + bounded.cmd("list-sessions") + + +def test_cmd_timeout_none_opts_out_of_the_server_default( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """An explicit ``timeout=None`` on ``Server.cmd`` overrides the server bound. + + ``timeout=self.timeout if timeout is None else timeout`` used to + collapse an explicit opt-out onto the server default -- indistinguishable + from omitting it -- so a caller could never run one command unbounded on + a server that has a timeout. + """ + binary, pid_file = hanging_tmux + bounded = Server(tmux_bin=binary, timeout=0.2) + outcome: list[object] = [] + + def call() -> None: + try: + outcome.append(bounded.cmd("list-sessions", timeout=None)) + except BaseException as e: # noqa: BLE001 + outcome.append(e) + + thread = threading.Thread(target=call, daemon=True) + thread.start() + thread.join(timeout=0.5) + assert thread.is_alive(), "explicit timeout=None must not use the server bound" + + # Unblock the thread directly; libtmux's own timeout/kill path is what + # this test verifies was never invoked. + os.kill(int(pid_file.read_text()), signal.SIGKILL) + thread.join(timeout=5) + assert not thread.is_alive() + + assert len(outcome) == 1 + assert not isinstance(outcome[0], exc.TmuxTimeout) + + +def test_context_manager_exit_kills_despite_is_alive_timeout( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``__exit__`` still attempts a kill when ``is_alive`` times out. + + A wedged server is "unknown", not "dead" -- treating the timeout as + "dead" would skip :meth:`Server.kill` and leak the daemon. ``__exit__`` + assumes alive and attempts the kill regardless. + """ + killed: list[bool] = [] + monkeypatch.setattr(server, "kill", lambda *a, **kw: killed.append(True)) + + def _boom() -> bool: + raise exc.TmuxTimeout(["tmux", "list-sessions"], 0.2) + + monkeypatch.setattr(server, "is_alive", _boom) + + with server: + pass + + assert killed == [True] + + def test_on_init(server: Server) -> None: """Verify on_init callback is called during Server initialization.""" called_with: list[Server] = [] @@ -365,6 +480,514 @@ def test_server_context_manager(TestServer: type[Server]) -> None: assert not server.is_alive() +def test_owned_server_removes_unused_socket_directory(tmp_path: pathlib.Path) -> None: + """An unused scope needs no executable and removes its private directory.""" + directory: pathlib.Path | None = None + try: + with Server.owned(tmux_bin=tmp_path / "missing-tmux") as owned: + assert owned.socket_path is not None + socket_path = pathlib.Path(owned.socket_path) + directory = socket_path.parent + assert directory.is_dir() + assert not socket_path.exists() + assert not directory.exists() + finally: + if directory is not None: + shutil.rmtree(directory, ignore_errors=True) + + +def test_owned_server_keeps_its_private_endpoint( + server: Server, + session: Session, +) -> None: + """Cleanup targets the created endpoint even if the yielded handle changes.""" + with Server.owned(tmux_bin=server.tmux_bin) as owned: + owned.new_session("temporary") + assert owned.socket_path is not None + socket_path = pathlib.Path(owned.socket_path) + assert socket_path.parent.stat().st_mode & 0o777 == 0o700 + assert owned.is_alive() + owned.socket_path = server.socket_path + owned.socket_name = server.socket_name + + assert not socket_path.parent.exists() + assert not Server(socket_path=socket_path).is_alive() + assert session in server.sessions + + +def test_owned_server_socket_path_equals_the_same_endpoint_by_string( + server: Server, +) -> None: + """``Server.owned``'s endpoint compares equal to itself addressed by ``str``. + + ``owned`` builds ``socket_path`` as a ``pathlib.Path``. ``__eq__`` + compares ``socket_path`` by value, and ``Path("/x") != "/x"``, so + passing that ``Path`` straight through used to make the owned server + compare unequal to the identical endpoint addressed by string -- + unlike every other constructor, which only ever sees a ``str``. + """ + with Server.owned(tmux_bin=server.tmux_bin) as owned: + assert owned.socket_path is not None + assert isinstance(owned.socket_path, str) + by_string = Server(socket_path=str(owned.socket_path), tmux_bin=server.tmux_bin) + assert owned == by_string + + +def test_owned_server_cleans_up_after_body_failure(server: Server) -> None: + """An exception still terminates the private daemon and removes its socket.""" + body_error = RuntimeError("body failed") + with ( + pytest.raises(RuntimeError, match="body failed"), + Server.owned(tmux_bin=server.tmux_bin) as owned, + ): + owned.new_session("temporary") + assert owned.socket_path is not None + socket_path = pathlib.Path(owned.socket_path) + raise body_error + assert not socket_path.parent.exists() + + +_OWNED_SIGNAL_CHILD_SCRIPT = """\ +import pathlib +import signal +import sys +import time + +# Reset to the default disposition explicitly: SIG_IGN survives exec, and +# an inherited ignore would make this child immune to the very signal the +# test is about to send, hanging the test for an unrelated reason. +signal.signal(signal.SIGTERM, signal.SIG_DFL) +if hasattr(signal, "SIGHUP"): + signal.signal(signal.SIGHUP, signal.SIG_DFL) + +from libtmux.server import Server + +marker = pathlib.Path(sys.argv[1]) +with Server.owned() as server: + server.new_session(session_name="io") + marker.write_text(str(server.socket_path)) + time.sleep(30) +""" + + +@pytest.mark.parametrize( + "sig", + [signal.SIGTERM, signal.SIGHUP], + ids=["SIGTERM", "SIGHUP"], +) +def test_owned_cleans_up_on_termination_signal( + tmp_path: pathlib.Path, + sig: signal.Signals, +) -> None: + """SIGTERM and SIGHUP no longer leak Server.owned()'s daemon (PY-12). + + Regression for a real defect: cleanup lived only in the context + manager's own ``finally``, which never ran on the default + disposition of SIGTERM or SIGHUP -- unlike SIGINT, which Python + already turns into ``KeyboardInterrupt`` before this code ever sees + it. Drives a *real* child process and sends it a *real* signal + end to end (not a direct call to the handler function), so a fix + that only works when invoked from within the same interpreter + cannot pass this by accident. + + The child dies by the signal (PY2-3): cleanup now runs from the + handler itself and re-raises against the process with the signal's + default disposition restored, so ``proc.returncode`` is negative + (``subprocess``'s convention for "killed by signal N"), not + ``128 + signum``. + """ + script = tmp_path / "owned_signal_child.py" + script.write_text(_OWNED_SIGNAL_CHILD_SCRIPT) + marker = tmp_path / "socket_path.txt" + + proc = subprocess.Popen( + [sys.executable, str(script), str(marker)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + deadline = time.monotonic() + 10 + while not marker.exists() and time.monotonic() < deadline: + if proc.poll() is not None: + break + time.sleep(0.05) + assert marker.exists(), ( + f"child never reported its socket_path; " + f"exited={proc.poll()!r} stderr follows on failure" + ) + socket_path = pathlib.Path(marker.read_text()) + + proc.send_signal(sig) + try: + returncode = proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + pytest.fail( + f"child did not exit within 5s of {sig.name}; " + "the signal leaked the daemon it was meant to reap" + ) + finally: + if proc.poll() is None: + proc.kill() + proc.wait(timeout=5) + stdout, stderr = proc.communicate() + + assert returncode == -sig, ( + f"expected exit {-sig} (killed by {sig.name}), got {returncode}\n" + f"stdout:\n{stdout}\nstderr:\n{stderr}" + ) + assert not Server(socket_path=socket_path).is_alive(), ( + "the private tmux daemon is still running after the signal" + ) + assert not socket_path.exists(), "the private socket file was left behind" + assert not socket_path.parent.exists(), ( + "the private socket directory was left behind" + ) + + +_OWNED_SIGNAL_SWALLOWED_CHILD_SCRIPT = """\ +import pathlib +import signal +import sys +import time + +signal.signal(signal.SIGTERM, signal.SIG_DFL) +if hasattr(signal, "SIGHUP"): + signal.signal(signal.SIGHUP, signal.SIG_DFL) + +from libtmux.server import Server + +marker = pathlib.Path(sys.argv[1]) +survived = pathlib.Path(sys.argv[2]) +with Server.owned() as server: + server.new_session(session_name="io") + marker.write_text(str(server.socket_path)) + try: + time.sleep(30) + except BaseException: + pass + survived.write_text("survived") + time.sleep(5) +""" + + +def test_owned_cleanup_and_death_survive_a_bare_except_in_the_block( + tmp_path: pathlib.Path, +) -> None: + """A broad ``except`` inside the block cannot keep the process alive. + + Regression for PY2-3: cleanup used to reach this method's ``finally`` + only by letting the trap's ``SystemExit`` unwind through the block's + own code, so a bare ``except:`` (or ``except BaseException:``) + wrapping code *inside* the ``with Server.owned():`` body caught it + before it ever got there -- the daemon stayed up, and the process kept + running well past its SIGTERM. Cleanup now runs from the signal + handler itself, and the process is killed by the signal directly + afterward, so no exception ever reaches the block's own ``except`` at + all. + """ + script = tmp_path / "owned_signal_swallowed_child.py" + script.write_text(_OWNED_SIGNAL_SWALLOWED_CHILD_SCRIPT) + marker = tmp_path / "socket_path.txt" + survived = tmp_path / "survived.txt" + + proc = subprocess.Popen( + [sys.executable, str(script), str(marker), str(survived)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + deadline = time.monotonic() + 10 + while not marker.exists() and time.monotonic() < deadline: + if proc.poll() is not None: + break + time.sleep(0.05) + assert marker.exists(), ( + f"child never reported its socket_path; " + f"exited={proc.poll()!r} stderr follows on failure" + ) + socket_path = pathlib.Path(marker.read_text()) + + proc.send_signal(signal.SIGTERM) + try: + returncode = proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + pytest.fail( + "child did not exit within 5s of SIGTERM; its own bare " + "except swallowed the exit" + ) + finally: + if proc.poll() is None: + proc.kill() + proc.wait(timeout=5) + stdout, stderr = proc.communicate() + + assert returncode == -signal.SIGTERM, ( + f"expected exit {-signal.SIGTERM} (killed by SIGTERM despite the " + f"block's own bare except), got {returncode}\n" + f"stdout:\n{stdout}\nstderr:\n{stderr}" + ) + assert not survived.exists(), ( + "the block's bare except ran past the signal and wrote its marker" + ) + assert not Server(socket_path=socket_path).is_alive(), ( + "the private tmux daemon is still running after the signal" + ) + assert not socket_path.exists(), "the private socket file was left behind" + assert not socket_path.parent.exists(), ( + "the private socket directory was left behind" + ) + + +def test_owned_session_cleans_up_by_id_after_rename( + server: Server, + session: Session, +) -> None: + """The created session is removed while pre-existing sessions survive.""" + with server.owned_session("temporary") as owned: + session_id = owned.session_id + owned.rename_session("renamed") + assert server.sessions.get(session_id=session_id, default=None) is None + assert session in server.sessions + + +def test_owned_session_kills_on_identity_guard_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A session missing an identity field is killed, not leaked. + + ``owned_session`` used to create the session, then run asserts and + ``int()`` conversions in a gap before its try/finally began, so a + failure there (or a bare ``assert`` skipped under ``python -O``) + leaked the session instead of triggering cleanup. + """ + real_new_session = Server.new_session + + def _new_session_missing_start_time( + self: Server, + *args: t.Any, + **kwargs: t.Any, + ) -> Session: + created = real_new_session(self, *args, **kwargs) + created.start_time = None + return created + + monkeypatch.setattr(Server, "new_session", _new_session_missing_start_time) + + with ( + pytest.raises(exc.LibTmuxException, match="start_time"), + server.owned_session("identity_guard_failure"), + ): + pass + + assert not server.has_session("identity_guard_failure") + + +def test_owned_session_refuses_an_existing_name( + server: Server, + session: Session, +) -> None: + """A failed creation never adopts or destroys the existing session.""" + with ( + pytest.raises(exc.TmuxSessionExists), + server.owned_session(session.session_name), + ): + pytest.fail("an existing session must not be yielded") + assert session in server.sessions + + +def test_owned_session_preserves_same_name_replacement(server: Server) -> None: + """Deleting the owned session does not transfer ownership to its old name.""" + with server.owned_session("temporary") as owned: + owned.kill() + replacement = server.new_session("temporary") + assert replacement in server.sessions + + +def test_owned_session_preserves_restarted_daemon(server: Server) -> None: + """Reused ids on a new daemon must not receive cleanup for the old daemon.""" + with Server.owned(tmux_bin=server.tmux_bin) as private: + with private.owned_session("original") as owned: + original_id = owned.session_id + private.kill() + replacement = private.new_session("replacement") + assert replacement.session_id == original_id + assert replacement in private.sessions + + +def test_owned_session_cleans_up_after_body_failure(server: Server) -> None: + """Cleanup runs during exception unwinding without swallowing the body error.""" + body_error = RuntimeError("body failed") + with ( + pytest.raises(RuntimeError, match="body failed"), + server.owned_session("temporary") as owned, + ): + session_id = owned.session_id + raise body_error + assert server.sessions.get(session_id=session_id, default=None) is None + + +def test_owned_session_preserves_body_and_cleanup_errors( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A cleanup timeout remains visible with the original body error chained.""" + run_command = common.run_command + body_error = RuntimeError("body failed") + + def run( + *args: object, + tmux_bin: str | None = None, + timeout: float | None = None, + ) -> common.CommandResult: + if "if-shell" in args: + raise exc.TmuxTimeout([str(arg) for arg in args], 0.1) + return run_command(*args, tmux_bin=tmux_bin, timeout=timeout) + + with ( + monkeypatch.context() as patch, + pytest.raises(exc.TmuxTimeout) as caught, + server.owned_session() as owned, + ): + patch.setattr(common, "run_command", run) + raise body_error + assert caught.value.__context__ is body_error + assert caught.value.timeout == 0.1 + assert owned in server.sessions + owned.kill() + + +@pytest.mark.parametrize("stderr", ["", "cleanup refused"]) +def test_owned_session_preserves_completed_cleanup_failure( + server: Server, + tmp_path: pathlib.Path, + stderr: str, +) -> None: + """A real child refuses cleanup; the error keeps the body failure chained.""" + executable = server.tmux_bin or shutil.which("tmux") + assert executable is not None + wrapper = tmp_path / "tmux-cleanup-refusal" + wrapper.write_text( + "#!/bin/sh\n" + 'for arg do\nif [ "$arg" = "if-shell" ]; then\n' + f"printf %s {shlex.quote(stderr)} >&2\nexit 7\nfi\ndone\n" + f'exec {shlex.quote(executable)} "$@"\n' + ) + wrapper.chmod(0o700) + refusing = Server( + socket_name=server.socket_name, + socket_path=server.socket_path, + tmux_bin=str(wrapper), + ) + body_error = RuntimeError("body failed") + + with ( + pytest.raises(exc.LibTmuxException) as caught, + refusing.owned_session() as owned, + ): + raise body_error + + assert caught.value.__context__ is body_error + assert (stderr or "Session cleanup exited with 7") in str(caught.value) + assert owned in server.sessions + owned.kill() + + +def test_owned_server_preserves_socket_after_cleanup_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Failed cleanup retains a reachable endpoint so the caller can retry.""" + run_command = common.run_command + cleanup_error = PermissionError("cleanup denied") + + def run( + *args: object, + tmux_bin: str | None = None, + timeout: float | None = None, + ) -> common.CommandResult: + if "kill-server" in args: + raise cleanup_error + return run_command(*args, tmux_bin=tmux_bin, timeout=timeout) + + # Bound in the try below only on success; an earlier failure (e.g. inside + # Server.owned itself) must not make the finally block dereference an + # unbound name and mask that failure behind an UnboundLocalError, which + # would also skip the kill and leak the daemon. + owned: Server | None = None + socket_path: pathlib.Path | None = None + try: + with ( + monkeypatch.context() as patch, + pytest.raises(PermissionError), + Server.owned(tmux_bin=server.tmux_bin) as owned, + ): + owned.new_session() + assert owned.socket_path is not None + socket_path = pathlib.Path(owned.socket_path) + patch.setattr(common, "run_command", run) + assert socket_path is not None + assert socket_path.exists() + assert owned.is_alive() + finally: + if owned is not None: + owned.kill() + if socket_path is not None: + shutil.rmtree(socket_path.parent) + + +@pytest.mark.parametrize( + ("returncode", "stderr"), [(7, ""), (7, "cleanup refused"), (0, "cleanup refused")] +) +def test_owned_server_preserves_socket_after_completed_cleanup_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + returncode: int, + stderr: str, +) -> None: + """A real cleanup refusal retains a reachable endpoint for retry.""" + executable = server.tmux_bin or shutil.which("tmux") + assert executable is not None + wrapper = tmp_path / "tmux-server-cleanup-refusal" + wrapper.write_text( + "#!/bin/sh\n" + 'for arg do\nif [ "$arg" = "kill-server" ]; then\n' + f"printf %s {shlex.quote(stderr)} >&2\nexit {returncode}\nfi\ndone\n" + f'exec {shlex.quote(executable)} "$@"\n' + ) + wrapper.chmod(0o700) + removed: list[pathlib.Path] = [] + socket_path: pathlib.Path | None = None + + try: + with monkeypatch.context() as patch, contextlib.ExitStack() as cleanup: + # Keep the endpoint reachable even if the assertion exposes a regression. + patch.setattr(shutil, "rmtree", removed.append) + owned = cleanup.enter_context(Server.owned(tmux_bin=str(wrapper))) + assert owned.socket_path is not None + socket_path = pathlib.Path(owned.socket_path) + owned.new_session() + with pytest.raises( + exc.LibTmuxException, + match=stderr or f"Server cleanup exited with {returncode}", + ): + cleanup.close() + assert not removed + assert socket_path.exists() + assert owned.is_alive() + finally: + if socket_path is not None: + Server(socket_path=str(socket_path), tmux_bin=executable).kill() + shutil.rmtree(socket_path.parent) + + class StartDirectoryTestFixture(t.NamedTuple): """Test fixture for start_directory parameter testing.""" @@ -473,11 +1096,55 @@ def test_new_session_start_directory_pathlib( assert actual_path == expected_path +def test_new_session_start_directory_with_newline( + server: Server, + tmp_path: pathlib.Path, +) -> None: + """A newline in ``start_directory`` must not corrupt the ``-P -F`` record. + + ``new_session`` parses its own record straight off ``proc.stdout[0]``, so + a value containing a newline -- here ``pane_current_path``, echoed back + because a session row also reports its active pane's fields -- used to + split the record across output lines. ``parse_output``'s strict ``zip`` + then rejected the truncated fragment with ``ValueError: zip() argument 2 + is shorter than argument 1`` before a ``Session`` was ever built. + """ + weird_directory = tmp_path / "we\nird" + weird_directory.mkdir() + + session = server.new_session( + session_name="test_newline_start_dir", + start_directory=weird_directory, + ) + + assert session.session_name == "test_newline_start_dir" + active_pane = session.active_window.active_pane + assert active_pane is not None + active_pane.refresh() + assert active_pane.pane_current_path is not None + actual_path = pathlib.Path(active_pane.pane_current_path).resolve() + assert actual_path == weird_directory.resolve() + + def test_tmux_bin_default(server: Server) -> None: """Default tmux_bin is None, falls back to shutil.which.""" assert server.tmux_bin is None +def test_timeout_has_class_level_default() -> None: + """``Server.timeout`` falls back like ``tmux_bin`` for a skipped ``__init__``. + + Every other configuration attribute (``socket_name``, ``socket_path``, + ``tmux_bin``, ...) is declared at class level, so an instance built + without going through ``__init__`` -- ``object.__new__``, or a subclass + whose own ``__init__`` does not call ``super().__init__()`` -- still has + a value to read. ``timeout`` was assigned only inside ``__init__``, so + the same construction path raised ``AttributeError`` on first use. + """ + bare = object.__new__(Server) + assert bare.timeout is None + + def test_tmux_bin_custom_path(caplog: pytest.LogCaptureFixture) -> None: """Custom tmux_bin path is used for commands. @@ -926,6 +1593,32 @@ def test_server_access_list(server: Server) -> None: assert isinstance(result, list) +def test_server_access_flags_precede_positional_user(server: Server) -> None: + """Boolean flags reach tmux's user lookup instead of its argv parser. + + ``server-access``'s own arg spec (``cmd-server-access.c``) declares + ``adlrw`` as value-less flags with the user as a single trailing + positional. Emitting ``-a myuser -r`` used to put ``myuser`` right + after ``-a``, so tmux's getopt-style parser stopped recognizing ``-r`` + as a flag once it saw that bare word and rejected the call as "too many + arguments" before ever looking up the user. + + ``server-access`` also refuses to touch the server owner's own entry + (``pw_uid == getuid()``), and this suite has no second real OS account + to allow -- so this proves the fix by reaching tmux's *next* validation + step (an unknown-user lookup) rather than failing on argv shape first. + """ + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip("server-access added in tmux 3.3") + + server.new_session(session_name="access_argv_order_test") + + with pytest.raises(exc.LibTmuxException, match="unknown user"): + server.server_access(allow="nonexistent-libtmux-test-user", read_only=True) + + def test_server_access_read_only_write_mutex(server: Server) -> None: """``read_only`` and ``write`` are mutually exclusive.""" from libtmux.common import has_gte_version @@ -968,10 +1661,10 @@ def fake_cmd(cmd: str, *args: str, **_kw: t.Any) -> t.Any: monkeypatch.setattr(server, "cmd", fake_cmd) server.server_access(allow="alice", read_only=True) - assert captured[-1][1:] == ("-a", "alice", "-r") + assert captured[-1][1:] == ("-a", "-r", "alice") server.server_access(allow="bob", write=True) - assert captured[-1][1:] == ("-a", "bob", "-w") + assert captured[-1][1:] == ("-a", "-w", "bob") def test_start_server(server: Server) -> None: @@ -1098,11 +1791,89 @@ def test_clear_prompt_history(server: Server) -> None: server.clear_prompt_history(prompt_type="command") -def test_wait_for_set_flag(server: Server) -> None: - """Test Server.wait_for() with set_flag.""" +def test_wait_for_signal(server: Server) -> None: + """Test Server.wait_for() with signal, tmux's own name for -S.""" server.new_session(session_name="wait_test") # Just set the flag — should not block or error - server.wait_for("test_channel_set", set_flag=True) + server.wait_for("test_channel_signal", signal=True) + + +def test_wait_for_set_flag_is_a_deprecated_alias_for_signal(server: Server) -> None: + """set_flag still works and warns; PY-8's natural spelling now also does.""" + server.new_session(session_name="wait_test_deprecated") + with pytest.deprecated_call(match="set_flag is deprecated in favor of signal"): + server.wait_for("test_channel_set", set_flag=True) + + +def test_wait_for_unsignalled_channel_times_out(server: Server) -> None: + """wait_for() bounds an unsignalled channel instead of blocking forever. + + Regression for PY-7: the signature previously had no timeout + parameter at all, and Server.owned()/Server() default to + Server.timeout=None (unbounded), so a caller had no way to escape a + channel that is never signalled. Bounded here well under this + suite's own per-test budget -- an unbounded call left in by mistake + would hang the run instead of merely failing it. + """ + server.new_session(session_name="wait_test_timeout") + started = time.monotonic() + with pytest.raises(exc.TmuxTimeout): + server.wait_for("never-signalled-py7", timeout=1) + elapsed = time.monotonic() - started + assert elapsed < 5, f"wait_for(timeout=1) took {elapsed:.2f}s to raise" + + +def test_wait_for_rejects_a_non_positive_timeout(server: Server) -> None: + """A zero or negative timeout is a caller error, not a silent no-op. + + Regression for PY2-7: ``subprocess.Popen.communicate(timeout=0)`` (or + a negative value) never gives the freshly spawned tmux process a + chance to respond -- it reads as expired immediately -- so + ``wait_for(channel, signal=True, timeout=0)`` raised ``TmuxTimeout`` + without ``wait-for -S`` ever running, silently dropping the signal + every caller of this bounded call was trusting to be sent. + """ + server.new_session(session_name="wait_test_zero_timeout") + + with pytest.raises(ValueError, match="timeout must be positive"): + server.wait_for("py2_7_zero", signal=True, timeout=0) + + with pytest.raises(ValueError, match="timeout must be positive"): + server.wait_for("py2_7_negative", signal=True, timeout=-1) + + # Control: a positive timeout still runs the command normally. + server.wait_for("py2_7_positive", signal=True, timeout=1) + + +def test_wait_for_lock_timeout_wedges_the_channel(server: Server) -> None: + """A timed-out lock wait leaves the channel unlockable afterward. + + Characterization for PY2-6 (a tmux limitation, documented on + :meth:`Server.wait_for`'s *lock* parameter rather than fixed): + ``cmd-wait-for.c`` hands a pending lock to the next queued locker on + unlock regardless of whether that locker gave up, and nothing removes + a locker whose own wait already raised ``TmuxTimeout``. A lock wait + bounded by *timeout* -- added this same fix round -- makes this + reachable from the library for the first time. + + A clean control on a different, untouched channel proves the + mechanism rather than merely that timeouts fire: lock, unlock, lock + again succeeds there. + """ + server.new_session(session_name="wait_test_lock_wedge") + + server.wait_for("py2_6_wedge", lock=True) + with pytest.raises(exc.TmuxTimeout): + server.wait_for("py2_6_wedge", lock=True, timeout=0.3) + server.wait_for("py2_6_wedge", unlock=True) + with pytest.raises(exc.TmuxTimeout): + server.wait_for("py2_6_wedge", lock=True, timeout=1) + + # Control: a channel nothing else contended for is not wedged. + server.wait_for("py2_6_control", lock=True) + server.wait_for("py2_6_control", unlock=True) + server.wait_for("py2_6_control", lock=True, timeout=1) + server.wait_for("py2_6_control", unlock=True) def test_run_shell_basic(server: Server) -> None: @@ -1419,6 +2190,31 @@ def _boom(**_: object) -> list[dict[str, str]]: assert list(server.clients) == [] +def test_server_clients_propagates_record_parse_error( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``Server.clients`` re-raises a malformed-record failure. + + A :exc:`~libtmux.exc.TmuxRecordParseError` means ``list-clients`` + ran and replied, but a value contained the field separator, so the + reply itself could not be split into records -- distinct from the + generic :exc:`~libtmux.exc.LibTmuxException` cases above, which mean + the invocation itself failed. Swallowing it into ``QueryList([])`` + would tell a caller "no clients" when tmux may hold clients libtmux + simply could not read back; ``Server.windows``/``Server.panes`` + already raise the same failure via ``_fetch_or_empty``. + """ + sentinel = exc.TmuxRecordParseError("simulated malformed record") + + def _boom(**_: object) -> list[dict[str, str]]: + raise sentinel + + monkeypatch.setattr("libtmux.server.fetch_objs", _boom) + with pytest.raises(exc.TmuxRecordParseError, match="simulated malformed record"): + list(server.clients) + + def test_server_search_sessions_propagates_errors( server: Server, monkeypatch: pytest.MonkeyPatch, @@ -1460,6 +2256,27 @@ def _boom(**_: object) -> list[dict[str, str]]: assert list(server.sessions) == [] +def test_server_sessions_propagates_record_parse_error( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``Server.sessions`` re-raises a malformed-record failure. + + Mirrors ``test_server_clients_propagates_record_parse_error``: a + :exc:`~libtmux.exc.TmuxRecordParseError` means the reply could not + be parsed, not that ``list-sessions`` was unreachable, so it is not + a case the empty-by-default contract covers. + """ + sentinel = exc.TmuxRecordParseError("simulated malformed record") + + def _boom(**_: object) -> list[dict[str, str]]: + raise sentinel + + monkeypatch.setattr("libtmux.server.fetch_objs", _boom) + with pytest.raises(exc.TmuxRecordParseError, match="simulated malformed record"): + list(server.sessions) + + def test_server_sessions_missing_socket_returns_empty(tmp_path: pathlib.Path) -> None: """A not-yet-created tmux socket preserves the empty-list contract.""" missing_server = Server(socket_path=tmp_path / "missing.sock") @@ -1483,6 +2300,35 @@ def _boom(**_: object) -> list[dict[str, str]]: assert list(server.sessions) == [] +def test_dead_server_reads_empty_but_a_prior_session_handle_raises( + server: Server, +) -> None: + """A killed server's sessions/windows/panes disagree on how to fail. + + Regression/pin for PY-9. ``Server.sessions``/``.windows``/``.panes`` + are lenient by default (see ``src/libtmux/AGENTS.md``), so a killed + server reads exactly like an empty live one through those. But a + ``Session``/``Window`` handle obtained *before* the kill is not + lenient at all: ``session.windows`` propagates + :exc:`~libtmux.exc.LibTmuxException`. ``server.sessions == []`` alone + can never tell a caller which case they are in. + """ + session = server.new_session(session_name="py9_dead_server") + window = session.active_window + + server.kill() + + assert list(server.sessions) == [] + assert list(server.windows) == [] + assert list(server.panes) == [] + assert server.is_alive() is False + + with pytest.raises(exc.LibTmuxException): + list(session.windows) + with pytest.raises(exc.LibTmuxException): + list(window.panes) + + def test_if_shell_true(server: Server) -> None: """Test Server.if_shell() with true condition.""" server.new_session(session_name="ifshell_test") @@ -1618,6 +2464,11 @@ def test_detach_all_clients_no_keep_preserves_one( server.detach_all_clients() + retry_until( + lambda: len(server.cmd("list-clients", "-F", "#{client_name}").stdout) == 1, + 2, + raises=True, + ) after = server.cmd("list-clients", "-F", "#{client_name}").stdout assert len(after) == 1 @@ -1695,16 +2546,13 @@ def test_server_display_message_flags( omits ``-t `` but still needs a client to receive stdout. The headless test environment provides one via :class:`ControlMode`. - Skipped on tmux 3.2a: ``display-message -p -c `` - returns empty stdout on that release (output dispatch via a control-mode - client was unreliable until later versions). + tmux 3.2a rejects ``display-message -c `` because its option + parser treats ``-c`` as a flag without an argument. """ from libtmux.common import has_gte_version if not has_gte_version("3.3"): - pytest.skip( - "display-message -p via control-mode client unreliable on tmux 3.2a" - ) + pytest.skip("display-message -c requires tmux 3.3+") if min_tmux_version and not has_gte_version(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") @@ -1719,15 +2567,14 @@ def test_server_display_message_flags( assert expected_in_output in output +@pytest.mark.filterwarnings("error") def test_server_display_message_no_text_returns_none( control_mode: t.Callable[..., t.Any], server: Server, ) -> None: """Without ``get_text=True`` the call renders to status line and returns None.""" - with control_mode() as ctl: - result = server.display_message( - "hi from libtmux", target_client=ctl.client_name - ) + with control_mode(): + result = server.display_message("hi from libtmux") assert result is None @@ -1739,9 +2586,7 @@ def test_server_display_message_target_client( from libtmux.common import has_gte_version if not has_gte_version("3.3"): - pytest.skip( - "display-message -p via control-mode client unreliable on tmux 3.2a" - ) + pytest.skip("display-message -c requires tmux 3.3+") with control_mode() as ctl: result = server.display_message( @@ -1765,3 +2610,34 @@ def test_server_display_message_warns_on_tmux_error( """ with pytest.warns(UserWarning, match="only one of -F or argument"): server.display_message("x", get_text=True, format_string="#{version}") + + +def test_server_timeout_bounds_every_command( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """A server-level timeout is the policy for commands through it.""" + binary, _pid_file = hanging_tmux + server = Server(tmux_bin=binary, timeout=0.3) + + with pytest.raises(exc.TmuxTimeout): + server.cmd("list-sessions") + + +@pytest.mark.parametrize("accessor", ["sessions", "windows", "panes", "clients"]) +def test_a_wedged_server_is_not_reported_as_empty( + hanging_tmux: tuple[str, pathlib.Path], + accessor: str, +) -> None: + """A listing must raise rather than answer empty. + + A tmux server that stopped answering has not said it has nothing. + Returning ``[]`` sends a caller on to create a session on a server + that already has them. Every accessor is covered because three of + them reimplemented the "empty means not ready" rule inline instead + of sharing it. + """ + binary, _pid_file = hanging_tmux + server = Server(tmux_bin=binary, timeout=0.3) + + with pytest.raises(exc.TmuxTimeout): + _ = getattr(server, accessor) diff --git a/tests/test_session.py b/tests/test_session.py index f7d95e4cca..d708c20ecc 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -10,7 +10,7 @@ import pytest -from libtmux import exc +from libtmux import Server, exc from libtmux.constants import WindowDirection from libtmux.pane import Pane from libtmux.session import Session @@ -18,9 +18,6 @@ from libtmux.test.random import namer from libtmux.window import Window -if t.TYPE_CHECKING: - from libtmux.server import Server - if t.TYPE_CHECKING: from typing import TypeAlias @@ -32,11 +29,21 @@ RaisesExc: TypeAlias = RaisesContext[Exception] # type: ignore[no-redef] from libtmux._internal.types import StrPath - from libtmux.server import Server logger = logging.getLogger(__name__) +@pytest.mark.parametrize("raw", [None, "0", "2"]) +def test_decoded_session_fields_are_local(raw: str | None) -> None: + """Attached-client counts preserve uncaptured and zero values.""" + session = Session( + server=Server(tmux_bin="missing-decoded-fields-tmux"), + session_attached=raw, + ) + assert session.attached_count == (None if raw is None else int(raw)) + assert session.session_attached == raw + + def test_has_session(server: Server, session: Session) -> None: """Server.has_session returns True if has session_name exists.""" TEST_SESSION_NAME = session.session_name diff --git a/tests/test_window.py b/tests/test_window.py index 73e6f61089..cb7d4fe7a1 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -5,7 +5,6 @@ import logging import pathlib import shutil -import time import typing as t import pytest @@ -20,6 +19,7 @@ ) from libtmux.pane import Pane from libtmux.server import Server +from libtmux.test.retry import retry_until from libtmux.window import Window if t.TYPE_CHECKING: @@ -29,6 +29,26 @@ logger = logging.getLogger(__name__) +@pytest.mark.parametrize("raw", [None, "0", "1"]) +def test_decoded_window_fields_are_local(raw: str | None) -> None: + """Window dimensions and flags decode without a running server.""" + window = Window( + server=Server(tmux_bin="missing-decoded-fields-tmux"), + window_width="80", + window_height="24", + window_active=raw, + ) + assert window.width_cells == 80 + assert window.height_cells == 24 + assert window.width == "80" + assert window.height == "24" + assert window.is_active is (None if raw is None else raw == "1") + window.window_width = None + window.window_height = None + assert window.width_cells is None + assert window.height_cells is None + + def test_select_window(session: Session) -> None: """Test Window.select_window().""" window_count = len(session.windows) @@ -557,10 +577,15 @@ def test_split_with_environment( environment=environment, ) assert pane is not None - # wait a bit for the prompt to be ready as the test gets flaky otherwise - time.sleep(0.05) + retry_until(lambda: "$" in "\n".join(pane.capture_pane()), 2, raises=True) for k, v in environment.items(): pane.send_keys(f"echo ${k}") + + def output_ready(expected: str = v) -> bool: + lines = pane.capture_pane() + return len(lines) >= 2 and lines[-2] == expected + + retry_until(output_ready, 2, raises=True) assert pane.capture_pane()[-2] == v @@ -907,6 +932,82 @@ def test_select_layout_next_previous(session: Session) -> None: assert layout_after_prev == layout_before +def test_select_layout_round_trip_is_byte_exact(session: Session) -> None: + """A saved ``window_layout`` fed back into ``select_layout`` is exact. + + tmux 3.8 made ``#{window_layout}`` JSON for non-control clients, while + ``select-layout`` still accepts the classic grammar too. libtmux treats + the value as an opaque token on every version -- it never parses or + validates it -- so a saved layout must restore byte-for-byte regardless + of which form the running tmux emits. + """ + window = session.new_window(window_name="test_layout_round_trip") + window.resize(height=40, width=80) + pane = window.active_pane + assert pane is not None + pane.split() + pane.split() + + window.select_layout("even-horizontal") + window.refresh() + saved = window.window_layout + assert saved is not None + + window.select_layout("main-vertical") + window.refresh() + assert window.window_layout != saved + + window.select_layout(saved) + window.refresh() + assert window.window_layout == saved + + +def test_select_layout_round_trip_preserves_pane_identity_on_json( + session: Session, +) -> None: + """On tmux 3.8+, restoring a saved layout puts each pane back in place. + + Positive proof for PY-2/D8: python exposes no public control-mode + client, so every caller is a plain reader -- ``#{window_layout}`` is + JSON from tmux 3.8 on, and JSON carries each pane's id. Restoring a + saved JSON layout from a *different* one must put every pane back + at its original position, not merely reproduce the same shape. + Before 3.8 the saved value is the classic string, which the + ``Notes`` on :meth:`Window.select_layout` document as + shape-exact but not identity-exact -- not asserted here, since + whether a given arrangement happens to rotate depends on tmux's own + internal pane order, not on anything libtmux controls. + """ + from libtmux.common import has_gte_version + + if not has_gte_version("3.8"): + pytest.skip("JSON window_layout, and its pane-identity guarantee, need 3.8+") + + window = session.new_window(window_name="test_layout_identity") + window.resize(height=40, width=80) + pane = window.active_pane + assert pane is not None + pane.split() + pane.split() + pane.split() + + window.select_layout("main-vertical-mirrored") + window.refresh() + saved = window.window_layout + assert saved is not None + assert saved.startswith("{"), "expected a JSON layout on tmux 3.8+" + before = {p.pane_id: (p.left_cells, p.top_cells) for p in window.panes} + + window.select_layout("even-horizontal") + window.refresh() + assert {p.pane_id: (p.left_cells, p.top_cells) for p in window.panes} != before + + window.select_layout(saved) + window.refresh() + after = {p.pane_id: (p.left_cells, p.top_cells) for p in window.panes} + assert after == before + + def test_last_pane(session: Session) -> None: """Test Window.last_pane() selects the previously active pane.""" window = session.new_window(window_name="test_last_pane") @@ -971,6 +1072,186 @@ def test_select_layout_mutual_exclusion(session: Session) -> None: window.select_layout("tiled", spread=True) +def test_select_layout_dash_o_is_a_layout_not_the_undo_flag(session: Session) -> None: + """A layout value beginning with ``-`` is never read as a tmux flag. + + Raw ``select-layout -o`` is tmux's *undo* flag (restores the previous + layout), not a layout named ``-o``. A caller passing a hostile or + accidental ``"-o"`` string must get a refusal, not a silent undo. + Regression for PY-1 -- before this fix, the call returned successfully + and undid the just-applied layout. + + Refused client-side (``ValueError``), before ever reaching tmux: on + tmux 3.3/3.3a, sending an actually-invalid layout *string* (which is + what "-o" becomes once forced to be read as one, rather than as the + undo flag) crashes the whole daemon instead of refusing cleanly -- + confirmed by hand against that version. A client-side refusal side- + steps that regardless of which tmux is running; see + ``test_select_layout_dash_o_crashes_tmux_3_3a_if_forced_through`` for + the raw-tmux confirmation this guards against. + """ + window = session.new_window(window_name="test_layout_dash_o") + window.resize(height=40, width=80) + pane = window.active_pane + assert pane is not None + pane.split() + + window.select_layout("even-horizontal") + window.refresh() + before = window.window_layout + + with pytest.raises(ValueError, match="looks like a tmux flag"): + window.select_layout("-o") + + # The undo flag would have restored the previous layout; a refusal + # must leave the current one untouched, and the server alive. + window.refresh() + assert window.window_layout == before + assert window.server.is_alive() + + +@pytest.mark.parametrize( + "value", + ["garbage", "no-such-preset", "next", "zzzz,80x24,0,0,0", "{not json"], +) +def test_select_layout_refuses_a_value_tmux_cannot_parse( + session: Session, + value: str, +) -> None: + """Only a preset name or a layout tmux reported reaches tmux. + + On tmux 3.3/3.3a any unparseable layout, not only one beginning with + ``-``, exits the daemon; without the refusal the server is gone there. + A JSON-looking value is refused only below 3.8, where it is unparseable. + """ + from libtmux.common import has_gte_version + + window = session.new_window(window_name="test_layout_unparseable") + if value.startswith("{") and not has_gte_version( + "3.8", + tmux_bin=session.server.tmux_bin, + ): + with pytest.raises(exc.VersionTooLow, match=r"3\.8"): + window.select_layout(value) + elif value.startswith("{"): + with pytest.raises(exc.LibTmuxException): + window.select_layout(value) + else: + with pytest.raises(ValueError, match=r"neither a preset name"): + window.select_layout(value) + assert window.server.is_alive() + + +@pytest.mark.parametrize("value", ["tile", "even-h"]) +def test_select_layout_accepts_a_unique_preset_prefix( + session: Session, + value: str, +) -> None: + """A prefix that resolves to exactly one preset applies (D3, PY2-4). + + tmux's own ``layout_set_lookup`` is a prefix match: ``"tile"`` and + ``"even-h"`` each name exactly one preset (``tiled``, + ``even-horizontal``) and apply on every supported tmux version, + including 3.3a, where an unparseable value would crash the daemon -- + a unique prefix never reaches that path. Before this fix, both were + refused with "neither a preset name nor a layout string tmux + reported", which is false: tmux knows exactly what they mean. + """ + window = session.new_window(window_name="test_layout_prefix") + window.select_layout(value) + assert window.server.is_alive() + + +def test_select_layout_refuses_an_ambiguous_prefix(session: Session) -> None: + """A prefix matching more than one preset is refused, naming both. + + ``"even-"`` prefixes both ``even-horizontal`` and ``even-vertical`` on + every version; raw tmux refuses it cleanly ("invalid layout: even-"), + and the client-side guard does too, but with a message that names the + candidates instead of claiming tmux does not know the spelling (D3). + """ + window = session.new_window(window_name="test_layout_ambiguous") + with pytest.raises(ValueError, match="is ambiguous between"): + window.select_layout("even-") + assert window.server.is_alive() + + +def test_select_layout_prefix_ambiguity_is_scoped_to_the_live_version( + session: Session, +) -> None: + """A prefix's ambiguity depends on which presets the live tmux has. + + ``"main-h"`` uniquely names ``main-horizontal`` below tmux 3.5, where + the mirrored presets don't exist yet, but is ambiguous with + ``main-horizontal-mirrored`` on 3.5+ -- confirmed against raw tmux on + 3.3a (applies) and 3.7c (refused, "invalid layout: main-h") before + this fix existed. + """ + from libtmux.common import has_gte_version + + window = session.new_window(window_name="test_layout_prefix_scoped") + if has_gte_version("3.5", tmux_bin=session.server.tmux_bin): + with pytest.raises(ValueError, match="is ambiguous between"): + window.select_layout("main-h") + else: + window.select_layout("main-h") + assert window.server.is_alive() + + +def test_select_layout_mirrored_preset_needs_tmux_3_5(session: Session) -> None: + """A mirrored preset below 3.5 is an unknown name to tmux, and fatal on 3.3a.""" + from libtmux.common import has_gte_version + + window = session.new_window(window_name="test_layout_mirrored") + if has_gte_version("3.5", tmux_bin=session.server.tmux_bin): + window.select_layout("main-vertical-mirrored") + else: + with pytest.raises(exc.VersionTooLow, match=r"3\.5"): + window.select_layout("main-vertical-mirrored") + assert window.server.is_alive() + + +def test_select_layout_dash_o_crashes_tmux_3_3a_if_forced_through( + server: Server, +) -> None: + """Raw tmux confirmation for the guard above's stated reason. + + Not a python defect: on tmux 3.3 and 3.3a specifically, forcing "-o" + to be read as a layout *string* (``select-layout -- -o``) frees an + uninitialized pointer and kills the daemon outright ("server exited + unexpectedly"), rather than refusing with an error -- fixed upstream + in 3.4. Skipped on every other version, where raw tmux refuses + cleanly and the server survives (already covered by this port's + matrix runs). This is *why* ``Window.select_layout`` refuses a + leading ``-`` itself instead of relying only on tmux's own response. + """ + from libtmux.common import get_version_str + + version = get_version_str(tmux_bin=server.tmux_bin) + if version not in {"3.3", "3.3a"}: + pytest.skip(f"tmux {version} is not the 3.3/3.3a crash case") + + server.new_session(session_name="crash_check") + proc = server.cmd("select-layout", "--", "-o") + assert proc.returncode != 0 + assert "server exited unexpectedly" in "\n".join(proc.stderr) + assert not server.is_alive() + + +def test_select_layout_empty_string_is_refused(session: Session) -> None: + """An explicit empty-string layout is refused, unlike omitting it. + + ``select_layout(None)`` is tmux's own "no layout" invocation (reapplies + the current layout); ``select_layout("")`` is a distinct, almost + certainly accidental call -- a caller-supplied value that happened to + be empty -- and silently falling back to the same behavior hides that + mistake. Regression for PY-3. + """ + window = session.new_window(window_name="test_layout_empty") + with pytest.raises(ValueError, match="empty string"): + window.select_layout("") + + def test_link_unlink_window(server: Server, session: Session) -> None: """Test Window.link() and Window.unlink().""" # Create a second session diff --git a/uv.lock b/uv.lock index d0a9ddac0f..2099be9462 100644 --- a/uv.lock +++ b/uv.lock @@ -764,6 +764,9 @@ version = "0.62.0" source = { editable = "." } [package.dev-dependencies] +benchmark = [ + { name = "pytest-benchmark" }, +] coverage = [ { name = "codecov" }, { name = "coverage" }, @@ -776,6 +779,7 @@ dev = [ { name = "gp-sphinx" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-benchmark" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, @@ -813,18 +817,20 @@ testing = [ [package.metadata] [package.metadata.requires-dev] +benchmark = [{ name = "pytest-benchmark", specifier = ">=5.3.0" }] coverage = [ { name = "codecov" }, - { name = "coverage" }, + { name = "coverage", specifier = ">=7.10.6" }, { name = "pytest-cov" }, ] dev = [ { name = "codecov" }, - { name = "coverage" }, + { name = "coverage", specifier = ">=7.10.6" }, { name = "gp-libs", specifier = ">=0.0.19" }, { name = "gp-sphinx", specifier = "==0.1.0a37" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-benchmark", specifier = ">=5.3.0" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, @@ -1151,6 +1157,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "py-cpuinfo2" +version = "10.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/97/a8b1ddada14c8280a047c0746f95cb05d94a31b1a331cea22bcdc2b2a82d/py_cpuinfo2-10.1.1.tar.gz", hash = "sha256:7861133863663f16e06eca63b12904ef100b5760415e92372dac0162799a4771", size = 100840, upload-time = "2026-03-25T21:49:40.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/0a/ba69d2dde1ae12ef1d389ea5a216384c5ff6ef7a1e7a48d1e9b6686f6790/py_cpuinfo2-10.1.1-py3-none-any.whl", hash = "sha256:adc53396bfb206e6498d078ec2ab407f85799ecd819584ac36a8f80a2d4d762d", size = 23791, upload-time = "2026-03-25T21:49:39.574Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -1178,6 +1193,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo2" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/8f/83a15e40dbc34a580ee56eb56983cae5394c6e94d50cf28fe268e457be25/pytest_benchmark-5.3.0.tar.gz", hash = "sha256:358444d4e89be901ee2b6404fb043ac3d7684002ad7f3563cc153fca6339c965", size = 375410, upload-time = "2026-08-23T17:45:08.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/42/7e80f7cfa191e0a766d1de99b4661847415ad5db34f8209d81fd42175b59/pytest_benchmark-5.3.0-py3-none-any.whl", hash = "sha256:920ab1dfcffa718d49aa15ba144c7e357bda59216a0dc308016cc1c7236f719d", size = 48401, upload-time = "2026-08-23T17:45:07.094Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0"