Skip to content

Commit 914e879

Browse files
committed
tests(race-conditions): Add xfail tests for staleness, DX, query, and ID recycling
why: Extend the race condition test suite into a comprehensive almanac of every discoverable state conflict in libtmux, beyond the initial 6 create-query tests. what: - Add Category 2: Object staleness after external mutation - Killed session active_window gives raw LibTmuxException - Killed session refresh correctly raises TmuxObjectDoesNotExist (passing) - Killed window panes gives raw LibTmuxException - Dead pane send_keys silently swallows error - Add Category 3: DX frustrations - Dead server sessions silently returns [] (bare except:pass) - Context manager rename works correctly (passing positive test) - Dead server windows/panes raise raw LibTmuxException (inconsistent) - Add Category 4: Query/filter edge cases - Filter typo field silently returns empty list - fetch_obj by mutable session_name fails after rename - Add Category 5: ID recycling after server restart - Stale ref with recycled $0 silently returns wrong session data
1 parent 9da278a commit 914e879

1 file changed

Lines changed: 304 additions & 9 deletions

File tree

tests/test_race_conditions.py

Lines changed: 304 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
1-
"""Race condition tests for object creation methods (#624).
2-
3-
Each test deterministically reproduces a failure mode where the two-step
4-
"create -> query" pattern in new_session(), new_window(), and split() fails.
5-
6-
All tests are strict xfail -- they document known vulnerabilities and will
7-
flip to XPASS when the creation methods are changed to construct objects
8-
directly from -P output.
1+
"""Race condition and state conflict tests for libtmux.
2+
3+
Documents every discoverable failure mode where libtmux's Python-side object
4+
state diverges from the tmux server's actual state. Organized into five
5+
categories:
6+
7+
1. **Create-query races** (#1-6): Two-step "create -> query" fails when state
8+
changes between the tmux command and the follow-up list query.
9+
2. **Object staleness after external mutation** (#7-10): Cached Python objects
10+
become invalid after the underlying tmux object is killed externally.
11+
3. **DX frustrations** (#11-15): Natural API usage patterns that produce
12+
confusing or silent failures.
13+
4. **Query/filter edge cases** (#16-17): QueryList and fetch_obj misbehavior
14+
on typos and renames.
15+
5. **ID recycling after server restart** (#18): Stale refs match wrong objects
16+
when tmux recycles numeric IDs.
17+
18+
All xfail tests are ``strict=True`` — they document known issues and will flip
19+
to XPASS when the underlying code is fixed, signaling that the xfail marker
20+
can be removed.
921
1022
Parallel-safe: each test uses the server/session fixture (unique socket)
11-
and function-scoped monkeypatch.
23+
and function-scoped monkeypatch where needed.
1224
"""
1325

1426
from __future__ import annotations
@@ -249,3 +261,286 @@ def fetch_objs_stale(
249261
pane = session.active_pane
250262
assert pane is not None
251263
pane.split()
264+
265+
266+
# --- Category 2: Object staleness after external mutation (#7-10) ---
267+
#
268+
# After creation, libtmux objects cache their data as dataclass attributes.
269+
# External changes (CLI user, other scripts, tmux hooks) invalidate that cached
270+
# state. Methods on stale objects produce wrong results or cryptic exceptions.
271+
272+
273+
@pytest.mark.xfail(
274+
reason="active_window on killed session raises LibTmuxException with raw "
275+
"tmux stderr (or NoActiveWindow) instead of a clear 'session dead' error. "
276+
"session.py:288-296 → self.windows → fetch_objs('list-windows') fails.",
277+
strict=True,
278+
)
279+
def test_session_killed_active_window(server: Server) -> None:
280+
"""Accessing active_window on an externally killed session gives unclear error.
281+
282+
The session object still exists in Python, but the underlying tmux session
283+
is gone. Accessing ``active_window`` should raise a clear, typed error
284+
indicating the session no longer exists — not ``LibTmuxException`` with raw
285+
tmux stderr or ``NoActiveWindow`` (which implies the session is alive but
286+
has no active window).
287+
"""
288+
session = server.new_session(session_name="doomed")
289+
session_id = session.session_id
290+
assert session_id is not None
291+
292+
# Kill via server.cmd to simulate external mutation
293+
server.cmd("kill-session", "-t", session_id)
294+
295+
# This should raise a clear "session dead" error, not raw stderr
296+
_window = session.active_window
297+
298+
299+
def test_session_killed_refresh(server: Server) -> None:
300+
"""Refreshing a killed session correctly raises an exception.
301+
302+
This is a **positive** test -- the current behavior is correct. ``refresh()``
303+
calls ``fetch_obj(obj_key='session_id', ...)`` which raises either
304+
``TmuxObjectDoesNotExist`` (when server is alive but session is gone) or
305+
``LibTmuxException`` (when the server died with the last session).
306+
307+
We keep a second session alive so the server survives the kill, ensuring
308+
``TmuxObjectDoesNotExist`` is raised.
309+
"""
310+
server.new_session(session_name="keeper")
311+
session = server.new_session(session_name="doomed")
312+
session_id = session.session_id
313+
assert session_id is not None
314+
315+
server.cmd("kill-session", "-t", session_id)
316+
317+
with pytest.raises(exc.TmuxObjectDoesNotExist):
318+
session.refresh()
319+
320+
321+
@pytest.mark.xfail(
322+
reason="Accessing panes on killed window raises LibTmuxException with raw "
323+
"tmux stderr instead of a typed 'window dead' error. "
324+
"window.py:179 → fetch_objs('list-panes', ['-t', window_id]) fails.",
325+
strict=True,
326+
)
327+
def test_window_killed_panes(session: Session) -> None:
328+
"""Accessing panes on an externally killed window gives unclear error.
329+
330+
After a window is killed externally, accessing ``window.panes`` triggers
331+
``fetch_objs`` with ``-t <dead_window_id>``, which causes tmux to emit
332+
a stderr error. This propagates as a raw ``LibTmuxException`` rather than
333+
a clear, typed error about the window being dead.
334+
"""
335+
window = session.new_window(window_name="doomed")
336+
window_id = window.window_id
337+
assert window_id is not None
338+
339+
session.cmd("kill-window", "-t", window_id)
340+
341+
# This should raise a clear "window dead" error, not raw stderr
342+
_panes = window.panes
343+
344+
345+
@pytest.mark.xfail(
346+
reason="send_keys to dead pane silently succeeds — server.cmd() returns "
347+
"result with stderr but send_keys ignores it. pane.py:469-471",
348+
strict=True,
349+
)
350+
def test_pane_killed_send_keys(session: Session) -> None:
351+
"""Sending keys to a killed pane silently fails instead of raising.
352+
353+
``send_keys`` calls ``self.cmd('send-keys', ...)`` which delegates to
354+
``server.cmd()``. The tmux command returns stderr about the invalid target,
355+
but ``send_keys`` ignores the return value entirely — the error is
356+
swallowed and the caller has no idea the operation failed.
357+
"""
358+
pane = session.active_pane
359+
assert pane is not None
360+
new_pane = pane.split()
361+
362+
session.cmd("kill-pane", "-t", new_pane.pane_id)
363+
364+
with pytest.raises(exc.LibTmuxException):
365+
new_pane.send_keys("echo hello")
366+
367+
368+
# --- Category 3: DX frustrations (#11-15) ---
369+
#
370+
# Scenarios a shell-user-turned-programmer would naturally attempt, where
371+
# libtmux's behavior is surprising or silently wrong.
372+
373+
374+
@pytest.mark.xfail(
375+
reason="server.sessions silently returns [] on dead server "
376+
"due to bare except:pass at server.py:615-616. "
377+
"Should raise or clearly indicate the server is dead.",
378+
strict=True,
379+
)
380+
def test_server_sessions_dead_server(server: Server) -> None:
381+
"""Dead server's sessions property should raise, not return empty list.
382+
383+
The ``sessions`` property has a bare ``except: pass`` (server.py:615) that
384+
swallows all exceptions from ``fetch_objs``. When the server is dead, this
385+
silently returns ``[]`` instead of raising an error — making it impossible
386+
for the caller to distinguish "server has no sessions" from "server is dead".
387+
"""
388+
server.new_session(session_name="exists")
389+
server.kill()
390+
391+
with pytest.raises(exc.LibTmuxException):
392+
_sessions = server.sessions
393+
394+
395+
def test_session_context_manager_rename(server: Server) -> None:
396+
"""Session context manager correctly kills renamed session on exit.
397+
398+
``Session.__exit__`` at session.py:130 checks
399+
``self.server.has_session(self.session_name)``. This works because
400+
``rename_session()`` calls ``self.refresh()`` which updates the cached
401+
``session_name`` in the Python object. So ``has_session('renamed')``
402+
returns True and ``kill()`` is called correctly.
403+
404+
This is a **positive** test documenting that the rename + context manager
405+
interaction works as expected.
406+
"""
407+
# Keep a session alive so the server survives after context exit
408+
server.new_session(session_name="keeper")
409+
410+
with server.new_session(session_name="original") as session:
411+
session.rename_session("renamed")
412+
413+
# After exiting context, session should have been killed
414+
assert not server.has_session("renamed"), (
415+
"Session leaked: __exit__ did not kill renamed session"
416+
)
417+
418+
419+
@pytest.mark.xfail(
420+
raises=exc.LibTmuxException,
421+
reason="server.windows has no try/except guard unlike server.sessions. "
422+
"server.py:620-637 lets LibTmuxException propagate from fetch_objs.",
423+
strict=True,
424+
)
425+
def test_server_windows_dead_server(server: Server) -> None:
426+
"""Accessing windows on dead server raises raw LibTmuxException.
427+
428+
Unlike ``server.sessions`` (which has a bare except:pass), ``server.windows``
429+
has no exception handling at all. When the server is dead, the
430+
``LibTmuxException`` from ``fetch_objs`` propagates directly — the
431+
inconsistency between the two properties is the bug.
432+
"""
433+
server.new_session(session_name="exists")
434+
server.kill()
435+
_windows = server.windows
436+
437+
438+
@pytest.mark.xfail(
439+
raises=exc.LibTmuxException,
440+
reason="server.panes has no try/except guard unlike server.sessions. "
441+
"server.py:639-656 lets LibTmuxException propagate from fetch_objs.",
442+
strict=True,
443+
)
444+
def test_server_panes_dead_server(server: Server) -> None:
445+
"""Accessing panes on dead server raises raw LibTmuxException.
446+
447+
Same inconsistency as ``server.windows`` -- no try/except guard while
448+
``server.sessions`` silently swallows errors.
449+
"""
450+
server.new_session(session_name="exists")
451+
server.kill()
452+
_panes = server.panes
453+
454+
455+
# --- Category 4: Query/filter edge cases (#16-17) ---
456+
#
457+
# QueryList and fetch_obj edge cases where typos or renames cause silent
458+
# wrong results instead of clear errors.
459+
460+
461+
@pytest.mark.xfail(
462+
reason="QueryList filter with typo field silently returns empty list "
463+
"instead of raising. keygetter() at query_list.py:108 catches all "
464+
"exceptions and returns None, causing the filter to find no matches.",
465+
strict=True,
466+
)
467+
def test_filter_typo_silent_empty(server: Server) -> None:
468+
"""Typo in filter field name silently returns empty result.
469+
470+
``keygetter()`` in query_list.py:99-113 uses a bare ``except Exception``
471+
that catches ``KeyError``/``AttributeError`` for unknown fields and returns
472+
``None``. This means a typo like ``sessionn_name`` (double 'n') silently
473+
produces zero matches instead of raising an ``AttributeError``.
474+
"""
475+
server.new_session(session_name="findme")
476+
result = server.sessions.filter(sessionn_name="findme")
477+
assert len(result) > 0, "Typo in filter field silently returned empty list"
478+
479+
480+
@pytest.mark.xfail(
481+
raises=exc.TmuxObjectDoesNotExist,
482+
reason="fetch_obj by session_name fails after rename — linear scan in "
483+
"neo.py:237-239 can't find old name.",
484+
strict=True,
485+
)
486+
def test_fetch_obj_renamed_session(server: Server) -> None:
487+
"""fetch_obj by session_name fails after session rename.
488+
489+
When a session is renamed between creation and a subsequent ``fetch_obj``
490+
call that uses ``session_name`` as the key, the linear scan at
491+
neo.py:237-239 fails because the old name no longer exists.
492+
493+
This documents that ``fetch_obj`` by mutable keys (like ``session_name``)
494+
is fragile — callers should prefer immutable keys (``session_id``).
495+
"""
496+
from libtmux.neo import fetch_obj
497+
498+
session = server.new_session(session_name="before_rename")
499+
session.rename_session("after_rename")
500+
501+
# This raises TmuxObjectDoesNotExist because "before_rename" is gone
502+
fetch_obj(
503+
server=server,
504+
obj_key="session_name",
505+
obj_id="before_rename",
506+
list_cmd="list-sessions",
507+
)
508+
509+
510+
# --- Category 5: ID recycling after server restart (#18) ---
511+
#
512+
# After a server restart, tmux recycles numeric IDs ($0, @0, %0). Stale
513+
# Python refs holding old IDs silently match the wrong new objects.
514+
515+
516+
@pytest.mark.xfail(
517+
reason="After server restart, stale session ref with recycled $0 ID "
518+
"silently returns data from a different session. neo.py:237-239 "
519+
"matches by session_id=$0 which now belongs to the imposter session.",
520+
strict=True,
521+
)
522+
def test_stale_ref_after_server_restart(server: Server) -> None:
523+
"""Stale object ref silently returns wrong data after server restart.
524+
525+
When the server is killed and restarted, tmux recycles numeric IDs.
526+
A stale Python ``Session`` object holding ``session_id='$0'`` will match
527+
the new session that also got ``$0``, silently returning data from a
528+
completely different session. There is no staleness detection.
529+
"""
530+
session = server.new_session(session_name="original")
531+
original_id = session.session_id
532+
assert original_id is not None
533+
534+
server.kill()
535+
536+
# Start a new session on the same server socket — gets recycled ID $0
537+
server.cmd("new-session", "-d", "-s", "imposter")
538+
539+
# Refresh the stale ref — should fail but $0 matches the imposter
540+
session.refresh()
541+
542+
# The data now belongs to "imposter", not "original"
543+
assert session.session_name == "original", (
544+
f"Stale ref returned '{session.session_name}' instead of 'original' — "
545+
f"ID {original_id} was recycled to a different session"
546+
)

0 commit comments

Comments
 (0)