|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import asyncio |
| 6 | +import threading |
| 7 | +import time |
5 | 8 | import typing as t |
6 | 9 |
|
| 10 | +from libtmux import exc |
| 11 | + |
7 | 12 | from libtmux_mcp._history import _prepare_spawn_environment |
8 | 13 | from libtmux_mcp._utils import ( |
9 | 14 | ExpectedToolError, |
|
16 | 21 | _resolve_window, |
17 | 22 | _serialize_pane, |
18 | 23 | handle_tool_errors, |
| 24 | + handle_tool_errors_async, |
19 | 25 | ) |
| 26 | +from libtmux_mcp._wait_policy import _wait_ceiling_seconds |
20 | 27 | from libtmux_mcp.models import ( |
| 28 | + PaneExitResult, |
21 | 29 | PaneInfo, |
22 | 30 | ) |
23 | 31 |
|
| 32 | +if t.TYPE_CHECKING: |
| 33 | + from libtmux.pane import Pane |
| 34 | + |
| 35 | +#: Longest single ``Pane.wait`` call. ``Pane.wait`` polls in a worker thread |
| 36 | +#: that cannot be interrupted, so the tool waits in slices this long and a |
| 37 | +#: cancelled call leaves a thread running for at most one of them. |
| 38 | +_EXIT_WAIT_SLICE_SECONDS = 0.5 |
| 39 | + |
24 | 40 | #: The four window corners ``find_pane_by_position`` accepts. |
25 | 41 | PaneCorner = t.Literal["top-left", "top-right", "bottom-left", "bottom-right"] |
26 | 42 |
|
@@ -347,3 +363,135 @@ def _innermost_score(p: t.Any) -> int: |
347 | 363 |
|
348 | 364 | matches.sort(key=_innermost_score, reverse=True) |
349 | 365 | return _serialize_pane(matches[0]) |
| 366 | + |
| 367 | + |
| 368 | +def _pane_option_state(pane: Pane) -> list[str]: |
| 369 | + """Return the pane's own ``remain-on-exit`` value, empty when unset.""" |
| 370 | + return list(pane.cmd("show-options", "-pqv", "remain-on-exit").stdout) |
| 371 | + |
| 372 | + |
| 373 | +def _restore_remain_on_exit(pane: Pane, previous: list[str]) -> None: |
| 374 | + """Put a pane's ``remain-on-exit`` back as :func:`_pane_option_state` saw it.""" |
| 375 | + if previous: |
| 376 | + pane.cmd("set-option", "-p", "remain-on-exit", previous[0]) |
| 377 | + else: |
| 378 | + pane.cmd("set-option", "-p", "-u", "remain-on-exit") |
| 379 | + |
| 380 | + |
| 381 | +def _wait_slice(pane: Pane, seconds: float, finished: threading.Event) -> t.Any: |
| 382 | + """Run one ``Pane.wait`` slice and flag its end, for the caller's cleanup. |
| 383 | +
|
| 384 | + ``Pane.wait`` restores ``remain-on-exit`` as it found it when it |
| 385 | + returns; a cancelled caller must let that finish before restoring the |
| 386 | + option itself, or the slice's restore lands last and wins. |
| 387 | + """ |
| 388 | + finished.clear() |
| 389 | + try: |
| 390 | + return pane.wait(timeout=seconds) |
| 391 | + finally: |
| 392 | + finished.set() |
| 393 | + |
| 394 | + |
| 395 | +@handle_tool_errors_async |
| 396 | +async def wait_for_pane_exit( |
| 397 | + pane_id: str | None = None, |
| 398 | + session_name: str | None = None, |
| 399 | + session_id: str | None = None, |
| 400 | + window_id: str | None = None, |
| 401 | + timeout: float = 30.0, |
| 402 | + socket_name: str | None = None, |
| 403 | +) -> PaneExitResult: |
| 404 | + """Wait for the process tmux started in a pane to exit; report how it ended. |
| 405 | +
|
| 406 | + Use after ``split_window(shell=...)`` or ``respawn_pane(shell=...)`` to |
| 407 | + learn a one-shot job's exit status or terminating signal without a |
| 408 | + prompt or a marker. It waits for the pane's own process, not for a |
| 409 | + command typed into a shell: for that use ``run_command``. |
| 410 | +
|
| 411 | + The pane stays on screen as a dead pane afterwards so its output can be |
| 412 | + read with ``capture_pane``; remove it with ``kill_pane``. A pane that |
| 413 | + closed before this call is gone and cannot be waited on, so start |
| 414 | + short-lived jobs in a pane created with ``remain-on-exit`` already on. |
| 415 | +
|
| 416 | + Parameters |
| 417 | + ---------- |
| 418 | + pane_id : str, optional |
| 419 | + Pane ID (e.g. '%1'). |
| 420 | + session_name : str, optional |
| 421 | + Session name for pane resolution. |
| 422 | + session_id : str, optional |
| 423 | + Session ID (e.g. '$1') for pane resolution. |
| 424 | + window_id : str, optional |
| 425 | + Window ID for pane resolution. |
| 426 | + timeout : float |
| 427 | + Maximum seconds to wait. Capped by the same server wait ceiling as |
| 428 | + ``wait_for_text``; the value enforced is reported as |
| 429 | + ``effective_timeout``. A pane still running at expiry is a result |
| 430 | + with ``timed_out=true``, not an error. |
| 431 | + socket_name : str, optional |
| 432 | + tmux socket name. |
| 433 | +
|
| 434 | + Returns |
| 435 | + ------- |
| 436 | + PaneExitResult |
| 437 | + Exit status and signal, or ``timed_out=true`` when the process is |
| 438 | + still running. |
| 439 | + """ |
| 440 | + if timeout <= 0: |
| 441 | + msg = "timeout must be positive" |
| 442 | + raise ExpectedToolError(msg) |
| 443 | + effective_timeout = min(timeout, _wait_ceiling_seconds()) |
| 444 | + |
| 445 | + server = _get_server(socket_name=socket_name) |
| 446 | + pane = _resolve_pane( |
| 447 | + server, |
| 448 | + pane_id=pane_id, |
| 449 | + session_name=session_name, |
| 450 | + session_id=session_id, |
| 451 | + window_id=window_id, |
| 452 | + ) |
| 453 | + target_pane_id = pane.pane_id |
| 454 | + if target_pane_id is None: |
| 455 | + msg = "resolved pane has no pane_id" |
| 456 | + raise ExpectedToolError(msg) |
| 457 | + |
| 458 | + started = time.monotonic() |
| 459 | + deadline = started + effective_timeout |
| 460 | + |
| 461 | + # Hold remain-on-exit across the slices: ``Pane.wait`` restores the |
| 462 | + # option on every return, and a process that exits between two slices |
| 463 | + # would otherwise close the pane and take its exit status with it. |
| 464 | + finished = threading.Event() |
| 465 | + finished.set() |
| 466 | + previous = await asyncio.to_thread(_pane_option_state, pane) |
| 467 | + await asyncio.to_thread(pane.cmd, "set-option", "-p", "remain-on-exit", "on") |
| 468 | + try: |
| 469 | + while True: |
| 470 | + remaining = deadline - time.monotonic() |
| 471 | + slice_seconds = max(min(_EXIT_WAIT_SLICE_SECONDS, remaining), 0.01) |
| 472 | + try: |
| 473 | + result = await asyncio.to_thread( |
| 474 | + _wait_slice, pane, slice_seconds, finished |
| 475 | + ) |
| 476 | + except exc.WaitTimeout: |
| 477 | + if time.monotonic() >= deadline: |
| 478 | + return PaneExitResult( |
| 479 | + pane_id=target_pane_id, |
| 480 | + exited=False, |
| 481 | + timed_out=True, |
| 482 | + elapsed_seconds=round(time.monotonic() - started, 3), |
| 483 | + effective_timeout=effective_timeout, |
| 484 | + ) |
| 485 | + continue |
| 486 | + return PaneExitResult( |
| 487 | + pane_id=target_pane_id, |
| 488 | + exited=True, |
| 489 | + exit_status=result.status, |
| 490 | + signal=result.signal, |
| 491 | + timed_out=False, |
| 492 | + elapsed_seconds=round(time.monotonic() - started, 3), |
| 493 | + effective_timeout=effective_timeout, |
| 494 | + ) |
| 495 | + finally: |
| 496 | + await asyncio.to_thread(finished.wait, _EXIT_WAIT_SLICE_SECONDS + 1.0) |
| 497 | + await asyncio.to_thread(_restore_remain_on_exit, pane, previous) |
0 commit comments