|
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. |
9 | 21 |
|
10 | 22 | Parallel-safe: each test uses the server/session fixture (unique socket) |
11 | | -and function-scoped monkeypatch. |
| 23 | +and function-scoped monkeypatch where needed. |
12 | 24 | """ |
13 | 25 |
|
14 | 26 | from __future__ import annotations |
@@ -249,3 +261,286 @@ def fetch_objs_stale( |
249 | 261 | pane = session.active_pane |
250 | 262 | assert pane is not None |
251 | 263 | 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