Skip to content

Scoping a Server no longer kills it - #728

Open
tony wants to merge 3 commits into
masterfrom
fix/724-server-kill-on-exit
Open

Scoping a Server no longer kills it#728
tony wants to merge 3 commits into
masterfrom
fix/724-server-kill-on-exit

Conversation

@tony

@tony tony commented Jul 29, 2026

Copy link
Copy Markdown
Member

Breaking change. Read the Before / After below before merging.

Summary

  • Fix with Server() as server: destroying a tmux daemon it had nothing to do with. __exit__ was if self.is_alive(): self.kill(), unconditionally — so a block that did no work at all took out the reader's default server and every session in it.
  • Change the default: leaving the block kills nothing.
  • Add kill_on_exit for when teardown is what you want.
  • Document why a server differs from a session, window and pane, on Server, __exit__, and in docs/topics/context_managers.md.

Why scoping is not ownership

A Server is a handle on a socket, not a connection. The same handle addresses a daemon whether or not this process started it, and a bare Server() addresses the default socket — usually the tmux the reader has been working in all day. Entering a with block says "I want this scoped", not "I am entitled to destroy this".

Session, Window and Pane are different in kind, and stay as they are: you got them by calling new_session() or split(), so the block really did create them.

Before / After

Server(socket_name="my-work").new_session(session_name="important")

with Server(socket_name="my-work") as server:
    pass

Server(socket_name="my-work").is_alive()   # False — the session is gone
Server(socket_name="my-work").new_session(session_name="important")

with Server(socket_name="my-work") as server:
    pass

Server(socket_name="my-work").is_alive()   # True — nothing was asked for

Ask for teardown explicitly:

with Server(socket_name="build", kill_on_exit=True) as server:
    server.new_session(session_name="ci")

Design decisions

Inference was implemented, then rejected. An earlier revision of this branch had __enter__ record whether the daemon was already alive and __exit__ kill only one the block started. It reads well and has real precedent — zipfile._filePassed, tarfile._extfileobj, gzip.myfileobj all decide cleanup by whether they opened the resource. It was dropped for two reasons.

The first is a defect. The ownership signal was is_alive(), which catches bare Exception and returns False by contract — every way of failing to reach a server is a "no". So a transient tmux failure at __enter__ would have read as "this block started it", and __exit__ would have killed a daemon that predated the block. That is this issue's bug, reachable through a different door, and closing it would have meant hardening a deliberately lenient predicate into a strict one.

The second is that the stdlib's inference cases infer from provenance — what the object did — not from liveness. Server has no provenance to record: the socket does not say who started the daemon. Inference here was a guess wearing the costume of a fact.

Explicit opt-in has its own precedent, including in languages built around ownership. subprocess.Popen.__exit__ unambiguously owns its child and still does not kill it. sqlite3.Connection.__exit__ commits or rolls back and never closes. Rust's std::process::Child has no impl Drop at all — "The standard library does not automatically wait on child processes (not even if the Child is dropped), it is up to the application developer to do so."

kill_on_exit is a plain bool, not a tri-state. With no inference there is no third state to spell. It also matches the codebase: every one of libtmux's 188 bool | None parameters is consumed as if x is not None and x:, so None already means False here — a None meaning "maybe kill" would have inverted the house idiom in the destructive direction.

The name is action-shaped on purpose. libtmux already ships Pane.display_popup(close_on_exit=...), and tmux's own vocabulary is remain-on-exit, exit-empty. An ownership-shaped name (owns_server) would assert a fact libtmux cannot verify.

No temp_server() helper. temp_server is already a doc anchor for the TestServer fixture, which creates auto-cleaned servers. A scoped-server helper can be designed on its own merits later.

Test plan

  • test_server_context_manager — the default leaves a server alive, including one the block started. This previously asserted the opposite; the change is the breaking change made visible in the suite
  • test_server_context_manager_spares_pre_existing_server — a pre-existing server survives an empty block
  • test_server_context_manager_spares_the_default_socket — a bare Server() under a monkeypatched $TMUX_TMPDIR. Every other test routes through the TestServer factory, which mints a private socket and structurally cannot reproduce this issue's shape
  • test_server_context_manager_kill_on_exit_true — the opt-in kills a server the block did not start
  • test_server_context_manager_kill_on_exit_true_when_block_started_it — and one it did
  • Doctests on Server, __enter__, __exit__, plus the reworked docs/topics/context_managers.md — all collected and run
  • Full suite clean, including libtmux.pytest_plugin, which leans on server teardown
  • uv run ruff check . / uv run ruff format . / uv run mypy clean
  • just build-docs clean

Follow-up, not in this PR

Session.from_session_id, Window.from_window_id and Pane.from_pane_id all exist, so a child handle can be obtained without creating the object — with Window.from_window_id(...) kills a window you did not make. Same bug class, smaller blast radius, and worth its own issue rather than widening this one.

Note for the merger

CHANGES will conflict with the sibling PRs for #723, #725 and #726, which all add to the same block — keep both sides. This one lands under ### Breaking changes.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.00000% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.35%. Comparing base (be7c8c1) to head (9114092).

Files with missing lines Patch % Lines
src/libtmux/server.py 50.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #728      +/-   ##
==========================================
- Coverage   52.45%   52.35%   -0.10%     
==========================================
  Files          26       26              
  Lines        3729     3732       +3     
  Branches      747      747              
==========================================
- Hits         1956     1954       -2     
- Misses       1469     1474       +5     
  Partials      304      304              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tony
tony force-pushed the fix/724-server-kill-on-exit branch from 846fb7c to 7ea0444 Compare July 30, 2026 23:29
@tony tony changed the title Server's context manager spares a server it did not start Scoping a Server no longer kills it Jul 30, 2026
@tony

tony commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Code review

Found 1 issue:

  1. Two sections further down context_managers.md still promise the old behaviour, so the page now contradicts itself. The commit message says it reworked the page "which promised unconditional teardown for all four classes" — two of those promises survive.

## Nested context managers
For complex setups, you can nest contexts to build a whole tmux hierarchy at
once and have every layer torn down for you:
```python
>>> with Server() as server:
... with server.new_session() as session:
... with session.new_window() as window:
... with window.split() as pane:
... pane.send_keys('echo "Hello"')
... # Do work with the pane
... # Everything is cleaned up automatically when exiting contexts
```
This ensures that:
1. The pane is killed when exiting its context
2. The window is killed when exiting its context
3. The session is killed when exiting its context
4. The server is killed when exiting its context
The cleanup happens in reverse order (pane → window → session → server), ensuring proper resource management.

"Nested context managers" still says the block gives you "every layer torn down for you", and item 4 reads "The server is killed when exiting its context". The with Server() as server: example immediately above it now leaves the daemon running.

## Benefits
Reaching for a context manager buys you a few things. Resources clean themselves
up the moment you leave the block, so you never manually call the
{meth}`~libtmux.Server.kill`, {meth}`~libtmux.Session.kill`,
{meth}`~libtmux.Window.kill`, or {meth}`~libtmux.Pane.kill` methods and the code
stays uncluttered. Because cleanup runs on the way out of the block, it fires
even when an exception unwinds the stack — so you don't leak a stray session or

"Benefits" then says "you never manually call the {meth}~libtmux.Server.kill ... methods", which is exactly what a reader now has to do.

The doctests cannot catch this: the nested block asserts no output, so the prose can drift from the behaviour silently. docs/AGENTS.md asks, in its pre-commit checklist, "Do the doctests run, and did you leave every code block, table, error string, and cross-reference exact?"

libtmux/docs/AGENTS.md

Lines 145 to 150 in 7ea0444

named by concept instead?
- Are the advanced and lower-level parts clearly marked opt-in?
- Do the doctests run, and did you leave every code block, table, error
string, and cross-reference exact?
- Did `just build-docs` stay clean — no new warning, no broken
cross-reference?

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@tony
tony force-pushed the fix/724-server-kill-on-exit branch 2 times, most recently from 600c845 to c023026 Compare August 1, 2026 10:41
tony added 2 commits August 1, 2026 05:45
why: A Server is a handle on a socket, not a connection -- the same
handle addresses a daemon whether or not this process started it, and a
bare Server() addresses whichever tmux the caller has open. Scoping one
to a block was destroying it, along with every session in it.

Inferring ownership from is_alive() was considered and rejected. That
predicate folds every way of failing to reach a server into False by
contract, so a transient tmux error at __enter__ would have read as
"this block started it" and killed a daemon that predated the block --
the same bug through a different door. Fixes #724.

what:
- Default kill_on_exit to False; __exit__ kills only when asked
- __enter__ returns self and probes nothing
- Say in the Server docstring that a server, unlike a session, window or
  pane, does not tear itself down, and why
- Rework docs/topics/context_managers.md, which promised unconditional
  teardown for all four classes
- Test the default, the opt-in, and a bare Server() on the default
  socket -- the shape the report was actually about, which the
  TestServer factory cannot reproduce
why: The page promised unconditional teardown for all four classes, so
a reader following it would expect `with Server()` to kill a server it
did not start.

what:
- Say that a `Server` context leaves the daemon running unless it was
  built with `kill_on_exit=True`, and show both.
- Correct the nested-context walkthrough, whose summary still listed
  the server among the layers torn down.
@tony
tony force-pushed the fix/724-server-kill-on-exit branch from c023026 to 60c8056 Compare August 1, 2026 10:46
why: A context manager that kills a server it did not start can take
down the tmux a user has been working in all day, so the change of
behaviour needs to be findable before someone upgrades into it.

what:
- Record the `kill_on_exit` breaking change under `Breaking changes`,
  with the migration a caller relying on the old teardown needs.
@tony
tony force-pushed the fix/724-server-kill-on-exit branch from 60c8056 to 9114092 Compare August 1, 2026 11:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant