Skip to content

Commit 0c644b8

Browse files
authored
fix(Session.attach()): Remove refresh() call that fails after session killed (#616)
Fixes an issue where `Session.attach()` would raise `TmuxObjectDoesNotExist` when a user: 1. Attaches to a tmux session via `tmuxp load` 2. Works in the session 3. Kills the session (e.g., closes all windows) before detaching 4. Detaches from tmux ### User Experience After running `tmuxp load`, users would see this traceback printed to their terminal after detaching: ``` Traceback (most recent call last): File "~/.local/bin/tmuxp", line 7, in <module> sys.exit(cli.cli()) ... File ".../libtmux/session.py", line 332, in attach self.refresh() File ".../libtmux/neo.py", line 167, in _refresh obj = fetch_obj(...) File ".../libtmux/neo.py", line 242, in fetch_obj raise exc.TmuxObjectDoesNotExist(...) libtmux.exc.TmuxObjectDoesNotExist: Could not find object ``` ### Root Cause `Session.attach()` called `self.refresh()` after the `attach-session` command returned. Since `attach-session` is a **blocking interactive command**, the session state can change arbitrarily during attachment - including being killed entirely. The `refresh()` call was semantically incorrect for interactive commands: - `attach-session` blocks until user detaches - Session state can change during attachment - Refreshing after such a command makes no sense ### Timeline - **Feb 2024**: 9a5147a - `Session.attach()` was added with the `refresh()` call - **Nov 2025**: tmuxp tmux-python/tmuxp@fdafdd2b switched from `attach_session()` to `attach()` - Users started experiencing the bug ### Changes - Removes `self.refresh()` call from `Session.attach()` - Adds regression test using NamedTuple + parametrize + test_id pattern
2 parents 64276e4 + 9826825 commit 0c644b8

File tree

3 files changed

+114
-2
lines changed

3 files changed

+114
-2
lines changed

CHANGES

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,27 @@ $ uvx --from 'libtmux' --prerelease allow python
3232

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

35+
### Breaking changes
36+
37+
#### Session.attach() no longer calls refresh() (#616)
38+
39+
{meth}`~libtmux.Session.attach` previously called {meth}`~libtmux.neo.Obj.refresh`
40+
after the `attach-session` command returned. This was semantically incorrect since
41+
`attach-session` is a blocking interactive command where session state can change
42+
arbitrarily during attachment.
43+
44+
This was never strictly defined behavior as libtmux abstracts tmux internals away.
45+
Code that relied on the session object being refreshed after `attach()` should
46+
explicitly call `session.refresh()` if needed.
47+
48+
### Bug fixes
49+
50+
#### Session.attach() no longer fails if session killed during attachment (#616)
51+
52+
Fixed an issue where {meth}`~libtmux.Session.attach` would raise
53+
{exc}`~libtmux.exc.TmuxObjectDoesNotExist` when a user killed the session while
54+
attached (e.g., closing all windows) and then detached.
55+
3556
## libtmux 0.52.1 (2025-12-07)
3657

3758
### CI

src/libtmux/session.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,6 @@ def attach(
329329
if proc.stderr:
330330
raise exc.LibTmuxException(proc.stderr)
331331

332-
self.refresh()
333-
334332
return self
335333

336334
def kill(

tests/test_session.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pathlib
77
import shutil
88
import typing as t
9+
from contextlib import nullcontext as does_not_raise
910

1011
import pytest
1112

@@ -18,6 +19,15 @@
1819
from libtmux.window import Window
1920

2021
if t.TYPE_CHECKING:
22+
from typing import TypeAlias
23+
24+
try:
25+
from _pytest.raises import RaisesExc
26+
except ImportError:
27+
from _pytest.python_api import RaisesContext # type: ignore[attr-defined]
28+
29+
RaisesExc: TypeAlias = RaisesContext[Exception] # type: ignore[no-redef]
30+
2131
from libtmux._internal.types import StrPath
2232
from libtmux.server import Server
2333

@@ -481,3 +491,86 @@ def test_new_window_start_directory_pathlib(
481491
actual_path = str(pathlib.Path(active_pane.pane_current_path).resolve())
482492
expected_path = str(user_path.resolve())
483493
assert actual_path == expected_path
494+
495+
496+
class SessionAttachRefreshFixture(t.NamedTuple):
497+
"""Test fixture for Session.attach() refresh behavior regression.
498+
499+
This tests the scenario where a session is killed while the user is attached,
500+
and then attach() tries to call refresh() which fails because the session
501+
no longer exists.
502+
503+
See: https://github.com/tmux-python/tmuxp/issues/1002
504+
"""
505+
506+
test_id: str
507+
raises: type[Exception] | bool
508+
509+
510+
SESSION_ATTACH_REFRESH_FIXTURES: list[SessionAttachRefreshFixture] = [
511+
SessionAttachRefreshFixture(
512+
test_id="session_killed_during_attach_should_not_raise",
513+
raises=False, # attach() should NOT raise if session gone
514+
),
515+
]
516+
517+
518+
@pytest.mark.parametrize(
519+
list(SessionAttachRefreshFixture._fields),
520+
SESSION_ATTACH_REFRESH_FIXTURES,
521+
ids=[test.test_id for test in SESSION_ATTACH_REFRESH_FIXTURES],
522+
)
523+
def test_session_attach_does_not_fail_if_session_killed_during_attach(
524+
server: Server,
525+
monkeypatch: pytest.MonkeyPatch,
526+
test_id: str,
527+
raises: type[Exception] | bool,
528+
) -> None:
529+
"""Regression test: Session.attach() should not fail if session is killed.
530+
531+
When a user is attached to a tmux session via `tmuxp load`, then kills the
532+
session from within tmux (e.g., kills all windows), and then detaches,
533+
the attach() method should not raise an exception.
534+
535+
Currently, attach() calls self.refresh() after attach-session returns, which
536+
fails with TmuxObjectDoesNotExist if the session no longer exists.
537+
538+
The fix is to remove the refresh() call from attach() since:
539+
1. attach-session is a blocking interactive command
540+
2. Session state can change arbitrarily while the user is attached
541+
3. Refreshing after such a command makes no semantic sense
542+
"""
543+
from libtmux.common import tmux_cmd
544+
545+
# Create a new session specifically for this test
546+
test_session = server.new_session(detach=True)
547+
548+
# Store original cmd method
549+
original_cmd = test_session.cmd
550+
551+
# Create a mock tmux_cmd result that simulates successful attach-session
552+
class MockTmuxCmd:
553+
def __init__(self) -> None:
554+
self.stdout: list[str] = []
555+
self.stderr: list[str] = []
556+
self.cmd: list[str] = ["tmux", "attach-session"]
557+
558+
def patched_cmd(cmd_name: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd:
559+
"""Patched cmd that kills session after attach-session."""
560+
if cmd_name == "attach-session":
561+
# Simulate: attach-session succeeded, user worked, then killed session
562+
# This happens BEFORE refresh() is called
563+
test_session.kill()
564+
return MockTmuxCmd() # type: ignore[return-value]
565+
return original_cmd(cmd_name, *args, **kwargs)
566+
567+
monkeypatch.setattr(test_session, "cmd", patched_cmd)
568+
569+
# Use context manager pattern for exception handling
570+
raises_ctx: RaisesExc = (
571+
pytest.raises(t.cast("type[Exception]", raises))
572+
if raises
573+
else t.cast("RaisesExc", does_not_raise())
574+
)
575+
with raises_ctx:
576+
test_session.attach()

0 commit comments

Comments
 (0)