Skip to content

Commit 2b3eb13

Browse files
committed
workspace/builder(fix[pane-readiness]): Wait for shell prompt before layout and commands
why: WorkspaceBuilder sends SIGWINCH (via select_layout) and commands (via send_keys) to panes before their shell finishes initializing (#365, open since 2018). With zsh, this triggers the PROMPT_SP partial-line marker — an inverse+bold "%" — because: tmux: screen_reinit() sets cx=cy=0 on new panes (screen.c:107-108). select_layout → layout_fix_panes → window_pane_resize enqueues a resize, and window_pane_send_resize delivers SIGWINCH via ioctl(TIOCSWINSZ) on the pane's PTY (window.c:449). zsh: preprompt() (utils.c:1530) runs the PROMPT_SP heuristic (utils.c:1545-1566) before every prompt. It outputs the PROMPT_EOL_MARK (default: "%B%S%#%s%b") to detect partial lines, then pads with spaces and returns the cursor. When SIGWINCH arrives mid-init, the interrupted redraw leaves the marker visible. what: - Add _wait_for_pane_ready() that polls cursor position until it moves from origin (0,0), indicating the shell has drawn its prompt - Call readiness check before select_layout in iter_create_panes() so SIGWINCH only arrives after the shell can handle resize gracefully - Wait for all default-shell panes, not just those with commands (blank panes via "- pane" shorthand expand to shell_command: []) - Remove redundant select_layout call in build() that doubled SIGWINCH signals after each pane yield - Add tests for readiness detection, timeout, call count, and layout deduplication
1 parent 3785ebc commit 2b3eb13

2 files changed

Lines changed: 219 additions & 5 deletions

File tree

src/tmuxp/workspace/builder.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,65 @@
2222

2323
logger = logging.getLogger(__name__)
2424

25+
26+
def _wait_for_pane_ready(
27+
pane: Pane,
28+
timeout: float = 2.0,
29+
interval: float = 0.05,
30+
) -> bool:
31+
"""Wait for pane shell to draw its prompt.
32+
33+
Polls the pane's cursor position until it moves from origin (0, 0),
34+
indicating the shell has finished initializing and drawn its prompt.
35+
36+
Parameters
37+
----------
38+
pane : :class:`libtmux.Pane`
39+
pane to wait for
40+
timeout : float
41+
maximum seconds to wait before giving up
42+
interval : float
43+
seconds between polling attempts
44+
45+
Returns
46+
-------
47+
bool
48+
True if pane became ready, False on timeout or error
49+
50+
Examples
51+
--------
52+
>>> pane = session.active_window.active_pane
53+
54+
Wait for the shell to be ready:
55+
56+
>>> _wait_for_pane_ready(pane, timeout=2.0)
57+
True
58+
"""
59+
start = time.monotonic()
60+
while time.monotonic() - start < timeout:
61+
try:
62+
pane.refresh()
63+
except Exception:
64+
logger.debug(
65+
"pane refresh failed during readiness check",
66+
extra={"tmux_pane": str(pane.pane_id)},
67+
)
68+
return False
69+
if pane.cursor_x != "0" or pane.cursor_y != "0":
70+
logger.debug(
71+
"pane ready, cursor moved from origin",
72+
extra={"tmux_pane": str(pane.pane_id)},
73+
)
74+
return True
75+
time.sleep(interval)
76+
logger.debug(
77+
"pane readiness check timed out after %.1f seconds",
78+
timeout,
79+
extra={"tmux_pane": str(pane.pane_id)},
80+
)
81+
return False
82+
83+
2584
COLUMNS_FALLBACK = 80
2685

2786

@@ -323,9 +382,6 @@ def build(self, session: Session | None = None, append: bool = False) -> None:
323382
assert isinstance(pane, Pane)
324383
pane = pane
325384

326-
if "layout" in window_config:
327-
window.select_layout(window_config["layout"])
328-
329385
if pane_config.get("focus"):
330386
focus_pane = pane
331387

@@ -507,6 +563,10 @@ def get_pane_shell(
507563

508564
assert isinstance(pane, Pane)
509565

566+
pane_shell = pane_config.get("shell", window_config.get("window_shell"))
567+
if pane_shell is None:
568+
_wait_for_pane_ready(pane)
569+
510570
if "layout" in window_config:
511571
window.select_layout(window_config["layout"])
512572

tests/workspace/test_builder.py

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
from tmuxp import exc
2525
from tmuxp._internal.config_reader import ConfigReader
2626
from tmuxp.cli.load import load_plugins
27-
from tmuxp.workspace import loader
28-
from tmuxp.workspace.builder import WorkspaceBuilder
27+
from tmuxp.workspace import builder as builder_module, loader
28+
from tmuxp.workspace.builder import WorkspaceBuilder, _wait_for_pane_ready
2929

3030
if t.TYPE_CHECKING:
3131
from libtmux.server import Server
@@ -1513,3 +1513,157 @@ def test_issue_800_default_size_many_windows(
15131513

15141514
builder.build()
15151515
assert len(server.sessions) == 1
1516+
1517+
1518+
def test_wait_for_pane_ready_returns_true(session: Session) -> None:
1519+
"""Verify _wait_for_pane_ready detects shell prompt."""
1520+
pane = session.active_window.active_pane
1521+
assert pane is not None
1522+
result = _wait_for_pane_ready(pane, timeout=2.0)
1523+
assert result is True
1524+
1525+
1526+
def test_wait_for_pane_ready_timeout(session: Session) -> None:
1527+
"""Verify _wait_for_pane_ready returns False on timeout for non-shell."""
1528+
window = session.active_window
1529+
assert window.active_pane is not None
1530+
new_pane = window.active_pane.split(shell="sleep 999")
1531+
assert new_pane is not None
1532+
result = _wait_for_pane_ready(new_pane, timeout=0.2)
1533+
assert result is False
1534+
1535+
1536+
class PaneReadinessFixture(t.NamedTuple):
1537+
"""Test fixture for pane readiness call count verification."""
1538+
1539+
test_id: str
1540+
yaml: str
1541+
expected_wait_count: int
1542+
1543+
1544+
PANE_READINESS_FIXTURES: list[PaneReadinessFixture] = [
1545+
PaneReadinessFixture(
1546+
test_id="waits_for_pane_with_commands",
1547+
yaml=textwrap.dedent(
1548+
"""\
1549+
session_name: readiness-test
1550+
windows:
1551+
- panes:
1552+
- shell_command:
1553+
- cmd: echo hello
1554+
- shell_command:
1555+
- cmd: echo world
1556+
""",
1557+
),
1558+
expected_wait_count=2,
1559+
),
1560+
PaneReadinessFixture(
1561+
test_id="waits_for_pane_without_commands",
1562+
yaml=textwrap.dedent(
1563+
"""\
1564+
session_name: readiness-test
1565+
windows:
1566+
- panes:
1567+
- shell_command:
1568+
- cmd: echo hello
1569+
- shell_command: []
1570+
""",
1571+
),
1572+
expected_wait_count=2,
1573+
),
1574+
PaneReadinessFixture(
1575+
test_id="skips_pane_with_custom_shell",
1576+
yaml=textwrap.dedent(
1577+
"""\
1578+
session_name: readiness-test
1579+
windows:
1580+
- panes:
1581+
- shell_command:
1582+
- cmd: echo hello
1583+
- shell: sleep 999
1584+
shell_command:
1585+
- cmd: echo world
1586+
""",
1587+
),
1588+
expected_wait_count=1,
1589+
),
1590+
]
1591+
1592+
1593+
@pytest.mark.parametrize(
1594+
list(PaneReadinessFixture._fields),
1595+
PANE_READINESS_FIXTURES,
1596+
ids=[t.test_id for t in PANE_READINESS_FIXTURES],
1597+
)
1598+
def test_pane_readiness_call_count(
1599+
tmp_path: pathlib.Path,
1600+
server: Server,
1601+
monkeypatch: pytest.MonkeyPatch,
1602+
test_id: str,
1603+
yaml: str,
1604+
expected_wait_count: int,
1605+
) -> None:
1606+
"""Verify _wait_for_pane_ready is called only for appropriate panes."""
1607+
call_count = 0
1608+
original = builder_module._wait_for_pane_ready
1609+
1610+
def counting_wait(
1611+
pane: Pane,
1612+
timeout: float = 2.0,
1613+
interval: float = 0.05,
1614+
) -> bool:
1615+
nonlocal call_count
1616+
call_count += 1
1617+
return original(pane, timeout=timeout, interval=interval)
1618+
1619+
monkeypatch.setattr(builder_module, "_wait_for_pane_ready", counting_wait)
1620+
1621+
yaml_workspace = tmp_path / "readiness.yaml"
1622+
yaml_workspace.write_text(yaml, encoding="utf-8")
1623+
workspace = ConfigReader._from_file(yaml_workspace)
1624+
workspace = loader.expand(workspace)
1625+
workspace = loader.trickle(workspace)
1626+
1627+
builder = WorkspaceBuilder(session_config=workspace, server=server)
1628+
builder.build()
1629+
assert call_count == expected_wait_count
1630+
1631+
1632+
def test_select_layout_not_called_after_yield(
1633+
tmp_path: pathlib.Path,
1634+
server: Server,
1635+
monkeypatch: pytest.MonkeyPatch,
1636+
) -> None:
1637+
"""Verify select_layout is called once per pane, not duplicated in build()."""
1638+
call_count = 0
1639+
original_select_layout = Window.select_layout
1640+
1641+
def counting_layout(self: Window, layout: str | None = None) -> Window:
1642+
nonlocal call_count
1643+
call_count += 1
1644+
return original_select_layout(self, layout)
1645+
1646+
monkeypatch.setattr(Window, "select_layout", counting_layout)
1647+
1648+
yaml_config = textwrap.dedent(
1649+
"""\
1650+
session_name: layout-test
1651+
windows:
1652+
- layout: main-vertical
1653+
panes:
1654+
- shell_command: []
1655+
- shell_command: []
1656+
- shell_command: []
1657+
""",
1658+
)
1659+
1660+
yaml_workspace = tmp_path / "layout.yaml"
1661+
yaml_workspace.write_text(yaml_config, encoding="utf-8")
1662+
workspace = ConfigReader._from_file(yaml_workspace)
1663+
workspace = loader.expand(workspace)
1664+
workspace = loader.trickle(workspace)
1665+
1666+
builder = WorkspaceBuilder(session_config=workspace, server=server)
1667+
builder.build()
1668+
# 3 panes = 3 layout calls (one per pane in iter_create_panes), not 6
1669+
assert call_count == 3

0 commit comments

Comments
 (0)