Skip to content
Merged
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
21 changes: 21 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@ $ uvx --from 'libtmux' --prerelease allow python

<!-- To maintainers and contributors: Please add notes for the forthcoming version below -->

### Breaking changes

#### Session.attach() no longer calls refresh() (#616)

{meth}`~libtmux.Session.attach` previously called {meth}`~libtmux.neo.Obj.refresh`
after the `attach-session` command returned. This was semantically incorrect since
`attach-session` is a blocking interactive command where session state can change
arbitrarily during attachment.

This was never strictly defined behavior as libtmux abstracts tmux internals away.
Code that relied on the session object being refreshed after `attach()` should
explicitly call `session.refresh()` if needed.

### Bug fixes

#### Session.attach() no longer fails if session killed during attachment (#616)

Fixed an issue where {meth}`~libtmux.Session.attach` would raise
{exc}`~libtmux.exc.TmuxObjectDoesNotExist` when a user killed the session while
attached (e.g., closing all windows) and then detached.

## libtmux 0.52.1 (2025-12-07)

### CI
Expand Down
2 changes: 0 additions & 2 deletions src/libtmux/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,8 +329,6 @@ def attach(
if proc.stderr:
raise exc.LibTmuxException(proc.stderr)

self.refresh()

return self

def kill(
Expand Down
93 changes: 93 additions & 0 deletions tests/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pathlib
import shutil
import typing as t
from contextlib import nullcontext as does_not_raise

import pytest

Expand All @@ -18,6 +19,15 @@
from libtmux.window import Window

if t.TYPE_CHECKING:
from typing import TypeAlias

try:
from _pytest.raises import RaisesExc
except ImportError:
from _pytest.python_api import RaisesContext # type: ignore[attr-defined]

RaisesExc: TypeAlias = RaisesContext[Exception] # type: ignore[no-redef]

from libtmux._internal.types import StrPath
from libtmux.server import Server

Expand Down Expand Up @@ -481,3 +491,86 @@ def test_new_window_start_directory_pathlib(
actual_path = str(pathlib.Path(active_pane.pane_current_path).resolve())
expected_path = str(user_path.resolve())
assert actual_path == expected_path


class SessionAttachRefreshFixture(t.NamedTuple):
"""Test fixture for Session.attach() refresh behavior regression.

This tests the scenario where a session is killed while the user is attached,
and then attach() tries to call refresh() which fails because the session
no longer exists.

See: https://github.com/tmux-python/tmuxp/issues/1002
"""

test_id: str
raises: type[Exception] | bool


SESSION_ATTACH_REFRESH_FIXTURES: list[SessionAttachRefreshFixture] = [
SessionAttachRefreshFixture(
test_id="session_killed_during_attach_should_not_raise",
raises=False, # attach() should NOT raise if session gone
),
]


@pytest.mark.parametrize(
list(SessionAttachRefreshFixture._fields),
SESSION_ATTACH_REFRESH_FIXTURES,
ids=[test.test_id for test in SESSION_ATTACH_REFRESH_FIXTURES],
)
def test_session_attach_does_not_fail_if_session_killed_during_attach(
server: Server,
monkeypatch: pytest.MonkeyPatch,
test_id: str,
raises: type[Exception] | bool,
) -> None:
"""Regression test: Session.attach() should not fail if session is killed.

When a user is attached to a tmux session via `tmuxp load`, then kills the
session from within tmux (e.g., kills all windows), and then detaches,
the attach() method should not raise an exception.

Currently, attach() calls self.refresh() after attach-session returns, which
fails with TmuxObjectDoesNotExist if the session no longer exists.

The fix is to remove the refresh() call from attach() since:
1. attach-session is a blocking interactive command
2. Session state can change arbitrarily while the user is attached
3. Refreshing after such a command makes no semantic sense
"""
from libtmux.common import tmux_cmd

# Create a new session specifically for this test
test_session = server.new_session(detach=True)

# Store original cmd method
original_cmd = test_session.cmd

# Create a mock tmux_cmd result that simulates successful attach-session
class MockTmuxCmd:
def __init__(self) -> None:
self.stdout: list[str] = []
self.stderr: list[str] = []
self.cmd: list[str] = ["tmux", "attach-session"]

def patched_cmd(cmd_name: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd:
"""Patched cmd that kills session after attach-session."""
if cmd_name == "attach-session":
# Simulate: attach-session succeeded, user worked, then killed session
# This happens BEFORE refresh() is called
test_session.kill()
return MockTmuxCmd() # type: ignore[return-value]
return original_cmd(cmd_name, *args, **kwargs)

monkeypatch.setattr(test_session, "cmd", patched_cmd)

# Use context manager pattern for exception handling
raises_ctx: RaisesExc = (
pytest.raises(t.cast("type[Exception]", raises))
if raises
else t.cast("RaisesExc", does_not_raise())
)
with raises_ctx:
test_session.attach()