Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/WRITING.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ docstring. `ELLIPSIS` and `NORMALIZE_WHITESPACE` are enabled globally
differences do not fail a comparison. Reach for an inline
`# doctest: +FLAG` only for the block that needs something more.

**Standalone programs.** `tests/test_example_harness.py` compares the
ordinary README program with `examples/session_scope.py` and runs that file
unchanged in a child process. Its unprompted README block uses this test
instead of the doctest collector. The harness supplies endpoint defaults and
checks cleanup after both successful execution and a body exception.

**`# doctest: +SKIP` is not permitted.** It is a workaround that tests
nothing. Use the fixtures.

Expand Down
57 changes: 57 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,63 @@ $ uvx --from 'libtmux' --prerelease allow python
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### Breaking changes

#### Server context and executable capture

A plain `Server` context leaves remote state intact. Use `server.own()` to accept whole-daemon destruction.

`Server` captures an absolute tmux executable path from its construction-time `PATH` and working directory. Commands, object queries, control clients, health checks and version probes use that executable and the captured child environment. A missing executable stays unresolved until a new handle is constructed. The read-only `tmux_bin` property reports the captured path or `None`. Version caches belong to each server handle; standalone `get_version()` and `get_version_str()` retain their existing cache behavior.

#### Captured server endpoints

{class}`~libtmux.Server` resolves explicit socket selectors, then
`LIBTMUX_SOCKET_PATH`, `LIBTMUX_SOCKET_NAME`, `TMUX`, and the default socket.
It retains the absolute endpoint and client environment for its lifetime.
Explicit paths must be absolute; passing both selectors raises `ValueError`.
Socket names reject separators, NUL, `.` and `..`. Named/default selectors
require an absolute `TMUX_TMPDIR` when set, and a missing root raises instead
of falling back to another endpoint. Create a new handle to select a changed
environment.

```python
import pathlib
import libtmux

# Before
server = libtmux.Server(socket_path="relative.sock")
# After
server = libtmux.Server(socket_path=pathlib.Path("relative.sock").absolute())
```

### What's new

#### Recovery after failed creation

Session, window and pane creation retain the creating daemon's token and object ID before decoding snapshots. Failed readback or context entry rolls back that resource against the original daemon. Nonzero client results, timeouts and interruptions retain readable creation receipts. `CreationCleanupError` exposes a failed rollback's owner for inspection and retry; `UnknownCreation` reports missing or unreadable receipts without claiming that no resource was created. Creation now reserves `@libtmux_owner_generation`, and an existing malformed value fails before the create command.

#### Explicit remote ownership

Call `.own()` on a server, session, window or pane to accept destruction responsibility. Owners retain the endpoint, daemon token and object ID, refuse replacement daemons, preserve paired body/cleanup failures and permit retry after failed teardown. Adoption reserves the server option `@libtmux_owner_generation`; invalid existing values fail acceptance. Session, window and pane contexts use the same ownership behavior. See {doc}`topics/context_managers` for complete examples.

`resource.own(timeout=...)` bounds acceptance and cleanup, including time spent waiting for another close call. `Server.cmd(timeout=...)` terminates and reaps a timed-out client. Output draining and reaping each allow up to 0.1 additional seconds, even when another process holds the client's output pipes. A timeout cannot undo a remote operation that the client already dispatched.

#### Client environment isolation and object cleanup

{class}`~libtmux.Server` accepts `child_environment` overrides and exposes an
immutable captured mapping. Client launches omit `TMUX` and `TMUX_PANE`
without changing the host environment. Session, window and pane scopes target
object IDs, including after a rename or a move to another parent.
`BaseExceptionGroup` retains both a body exception and a cleanup exception. Python 3.10 uses the `exceptiongroup` backport; Python 3.11 and later use the built-in exception group.
A failed cleanup can be retried; a prior successful destruction is harmless.
`temp_window` uses the window scope's cleanup. Pytest finalizers retain exact
endpoints and report cleanup failures.

{class}`~libtmux.test.environment.EnvironmentVarGuard` restores each key's
original state after repeated edits, including prior absence and exceptions.
See {doc}`topics/configuration` for endpoint precedence and the separate
client and tmux environment APIs.

### Documentation

#### Cleaner `from_env` examples (#719)
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,32 @@ libtmux = "0.50.*"

## 🚀 Quickstart

Create a session at your configured tmux endpoint and remove it on scope exit.
Save this program as `session_scope.py`, or run
[`examples/session_scope.py`](examples/session_scope.py):

```python
"""Create and clean up a session at the ordinary configured endpoint."""

from __future__ import annotations

import uuid

import libtmux

server = libtmux.Server()
with server.new_session(session_name=f"libtmux-example-{uuid.uuid4().hex}") as session:
print(session.session_id, flush=True)
```

`Server()` captures its endpoint at construction: explicit `socket_path` or
`socket_name`, then `LIBTMUX_SOCKET_PATH`, `LIBTMUX_SOCKET_NAME`, `TMUX`, or the
default socket. For named/default sockets, `TMUX_TMPDIR` selects the root.
Empty selector variables count as absent. Later environment changes do not
redirect an existing handle. The [external harness](tests/test_example_harness.py)
runs this same file unchanged under both private path and name defaults,
including a body failure that must still remove the session.

### Open a tmux session

First, start a tmux session to connect to:
Expand All @@ -110,7 +136,7 @@ Connect to a live tmux session:
>>> import libtmux
>>> svr = libtmux.Server()
>>> svr
Server(socket_path=/tmp/tmux-.../default)
Server(socket_path=.../tmux-.../default)
```

**Tip:** You can also use [tmuxp]'s [`tmuxp shell`] to drop straight into your
Expand Down
1 change: 1 addition & 0 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,5 @@ Options <libtmux.options>
Hooks <libtmux.hooks>
Constants <libtmux.constants>
Exceptions <libtmux.exc>
Ownership <libtmux.lifecycle>
```
6 changes: 6 additions & 0 deletions docs/api/libtmux.lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Ownership

```{eval-rst}
.. automodule:: libtmux.lifecycle
:members: Owned, OwnedIdentity, CreationCleanupError, UnknownCreation
```
4 changes: 3 additions & 1 deletion docs/api/libtmux.neo.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ Attach default tmux {class}`~libtmux.Server` to `t`:
>>> import libtmux
>>> t = libtmux.Server()
>>> t
Server(socket_path=/tmp/tmux-.../default)
Server(socket_path=...)
```

The representation shows the absolute endpoint selected from your arguments or environment. Its location depends on your tmux configuration.

## Session

Get the {class}`~libtmux.Session` object:
Expand Down
4 changes: 3 additions & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,11 @@ First, we can grab a {class}`~libtmux.Server`.
>>> import libtmux
>>> server = libtmux.Server()
>>> server
Server(socket_path=/tmp/tmux-.../default)
Server(socket_path=...)
```

The representation shows the absolute endpoint selected from your arguments or environment. Its location depends on your tmux configuration.

:::{tip}

You can also use [tmuxp]'s [`tmuxp shell`] to drop straight into your
Expand Down
109 changes: 64 additions & 45 deletions docs/topics/configuration.md
Original file line number Diff line number Diff line change
@@ -1,58 +1,77 @@
# Configuration

You configure libtmux through Python: there are no config files, and you
set everything through method calls on {class}`~libtmux.Server`,
{class}`~libtmux.Session`, {class}`~libtmux.Window`, and
{class}`~libtmux.Pane` objects, with sensible defaults. If you're driving
tmux through the standard object API, you're already configured correctly
and can stop reading here.

The rest of this page is for the rarer cases. It documents two lower
layers you can reach for when the defaults aren't enough: the
environment variables libtmux reads, and the format-string system
libtmux uses internally to read tmux state.
{class}`~libtmux.Server` captures one tmux endpoint when you construct it.
You can use ordinary defaults, pass a socket selector, or configure the same
program from its launch environment. Commands and cleanup retain that
endpoint after you change the host environment.

## Environment variables

You set almost nothing here. The two variables that matter most, tmux
writes for you and libtmux only reads back, so a normal Python process
driving tmux has nothing to arrange in this section.
The first selected value wins:

tmux exports both into every pane it spawns:
1. An explicit `socket_path` or `socket_name`. Passing both raises `ValueError`.
2. Nonempty `LIBTMUX_SOCKET_PATH`.
3. Nonempty `LIBTMUX_SOCKET_NAME`.
4. Nonempty `TMUX`, parsed as `socket_path,server_pid,session_id`.
5. The named `default` socket.

| Variable | What tmux puts in it |
|---|---|
| `TMUX` | the server that pane belongs to, as `socket_path,server_pid,session_id` |
| `TMUX_PANE` | the id of the pane itself, e.g. `%1` |
Empty environment selectors count as absent. An invalid selected value raises
without trying a lower priority selector. Lower priority values cannot
invalidate an explicit choice. Paths must be absolute and contain no NUL;
libtmux preserves spaces and commas. Socket names must be nonempty leaf
names without `/`, `\`, NUL, `.` or `..`.

Code running *inside* a pane — a script you started in a split, a hook, a
test harness — reads them back to get a handle on itself, rather than
searching the server for a pane it already is. That is the `from_env`
family: {meth}`Server.from_env() <libtmux.Server.from_env>`,
For a named/default socket, libtmux captures nonempty `TMUX_TMPDIR` or `/tmp`
and derives `<root>/tmux-<uid>/<name>`. The supplied root must be absolute
and exist when you issue a command. Libtmux creates the private per-UID
directory if needed, checks its owner and permissions, and passes the captured
path to tmux. A missing or removed root raises; an explicit path does not
create its parent directory. `socket_path` exposes the captured absolute
path for named sockets too. `socket_name_factory` supplies an explicit name
when you omit both explicit selectors.

`TMUX` splits from the last two commas so the socket path can contain commas.
Its PID must be positive ASCII decimal; its session field accepts a
nonnegative ASCII decimal ID, one optional `$` prefix, or tmux's `-1`
no-session sentinel. Malformed context raises
{exc}`~libtmux.exc.NotInsideTmux`. The selected path still requires an
absolute path. {meth}`Server.from_env() <libtmux.Server.from_env>` reads a
supplied mapping or the host `TMUX` context; child
{meth}`Session.from_env() <libtmux.Session.from_env>`,
{meth}`Window.from_env() <libtmux.Window.from_env>`, and
{meth}`Pane.from_env() <libtmux.Pane.from_env>`. Outside a pane neither
variable is set, and all four raise {exc}`~libtmux.exc.NotInsideTmux`.
You never write them yourself: {ref}`self-location` covers what each call
does with them, why the session id in `TMUX` goes stale, and the `env`
mapping you hand `from_env` in tests instead of touching the real
environment.

tmux reads `TMUX` too — it is how tmux notices you are already inside a
session and guards against nesting one. {meth}`Server.new_session()
<libtmux.Server.new_session>` unsets it for the length of that one call
and restores it afterward, so creating a session from inside a pane works
without you arranging anything.

That leaves the two variables that *are* yours to set, and most people
set neither. `TMUX_TMPDIR` is tmux's own — the directory it keeps sockets
in. libtmux never reads it, but the tmux binary it shells out to does, so
it shapes which server a bare {class}`~libtmux.Server` lands on; pass
`socket_name` or `socket_path` when you would rather name the server
outright. `LIBTMUX_TMUX_FORMAT_SEPARATOR` is the one variable libtmux
itself defines: an advanced override for the separator (default `␞`) it
uses internally to parse tmux's format output — you'd touch it only if
that character ever collided with your own data.
{meth}`Pane.from_env() <libtmux.Pane.from_env>` also resolve `TMUX_PANE`
against the live server. See {ref}`self-location` for pane context.

## Client and tmux environments

`child_environment` supplies overrides for a copy of the host environment at
construction. The endpoint resolver reads that copy, then client launches
receive its immutable snapshot with `TMUX` and `TMUX_PANE` removed. Commands,
including {meth}`Server.new_session() <libtmux.Server.new_session>`, leave the
host environment unchanged. `child_environment` cannot redirect an explicit
socket selector. There is no `LIBTMUX_SOCKET_ENV` variable.

The constructor also resolves `tmux_bin` against the captured `PATH` and working directory. `server.tmux_bin` reports that absolute path, or `None` when a bare executable name was not found. Adding an executable to `PATH` later does not repair an existing handle; construct a new one. The captured path preserves filesystem traversal, including symlinks and `..`; it does not pin the executable's bytes against replacement on disk. Commands, object queries, control clients, health checks and version probes all use this path and the captured environment. Each handle caches its own version probe. The standalone `libtmux.common.get_version()` and `get_version_str()` functions keep their separate process-wide caches.

The `environment=` argument to session, window and pane creation configures
tmux's environment for those resources. Methods such as
{meth}`~libtmux.common.EnvironmentMixin.set_environment` change tmux's
server/session environment. They do not edit a handle's captured client
environment or restore earlier tmux values on scope exit.

{class}`~libtmux.test.environment.EnvironmentVarGuard` edits process-global
variables and restores their original value or absence after repeated edits
and exceptions. Concurrent writers share that host state. Prefer a child
environment for an external example harness: it leaves the parent's
environment unchanged and keeps whole-server cleanup outside the ordinary
program. The repository's `tests/test_example_harness.py` executes
`examples/session_scope.py` unchanged using both socket selector variables,
checks session cleanup after success and body failure, then destroys its
private server. On Linux it checks daemon exit through a retained PID handle
and checks the endpoint before releasing its temporary root.

`LIBTMUX_TMUX_FORMAT_SEPARATOR` configures the separator (default `␞`) used
to parse tmux's format output. It has no role in endpoint selection.

## Format strings

Expand Down
Loading
Loading