From 5cef952ad922805b1ccc7f3bfe25136bc237b129 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 06:49:54 -0500 Subject: [PATCH 1/3] perf(workspace[wait]) tighten _wait_for_pane_ready polling interval to 10ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Empirical microbenchmark (60-pane test suite) measured the previous 50ms interval costing ~41ms more per pane on subprocess and ~35ms more on imsg than a 10ms interval, because tmux's shell-ready latency lands in the 50-150ms band and the coarser poll missed the early-readiness window. Tightening to 10ms saves ~2.5s wall time on tmuxp's suite with no correctness change — same condition, just polled more frequently. what: - _wait_for_pane_ready: change interval default from 0.05 to 0.01 --- src/tmuxp/workspace/builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 728b477963..94c2f1d801 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -27,7 +27,7 @@ def _wait_for_pane_ready( pane: Pane, timeout: float = 2.0, - interval: float = 0.05, + interval: float = 0.01, ) -> bool: """Wait for pane shell to draw its prompt. From 6ccb0590f4d9644a04fe3e5348b08729ec481e67 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 06:51:52 -0500 Subject: [PATCH 2/3] workspace(feat[builder]) add _bulk_set_options helper for set-option loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmuxp's workspace builder fires N round-trips per workspace load applying session/window/options_after metadata, one ``set-option`` per loop iteration. Centralising those calls behind a single helper means we have one place to swap in a pipelined dispatch (e.g. ``Server.batch()``) the moment libtmux exposes one. The helper lands first without call-site changes so behaviour parity can be verified in isolation. The original draft of this work (``libtmux-protocol`` branch, ``f4c95faa``) used ``Server.batch()`` directly. That API doesn't exist on libtmux 0.56.0 (it ships with the unmerged libtmux-protocol-engines work), and even when present its perf gain is documented as "no-op cost on subprocess and imsg" — so batching only pays off on the unshipped control_mode engine. To land the API shape against current libtmux without committing to unshipped upstream, this commit issues one ``set-option`` per item via ``server.cmd``. The helper API (mapping, target, scope_flag) is deliberately batch-shaped so the body can swap back to a single batched dispatch later without touching the call sites. what: - Add ``WorkspaceBuilder._bulk_set_options(items, *, target, scope_flag)`` mirroring ``OptionsMixin.set_option``'s bool -> "on"/"off" normalisation (libtmux/options.py:712) so loop and helper paths produce identical tmux commands. - Reuse libtmux's ``handle_option_error`` to preserve the ``OptionError`` subclasses callers already catch — the same propagation ``OptionsMixin.set_option`` does inside its own body (libtmux/options.py:712). - Empty-mapping guard avoids a zero-iteration loop. - Add two regression tests in tests/workspace/test_builder.py: ``test_bulk_set_options_propagates_unknown_option_error`` proves bad options still raise ``OptionError`` from the helper; ``test_bulk_set_options_applies_session_window_and_options_after`` exercises the helper across all three scopes (-s, -g, -w) and verifies each option lands on tmux via ``show_option`` / ``show-option -v``. Salvaged from libtmux-protocol@f4c95faa with the body rewritten to drop the unshipped ``Server.batch()`` dependency. Test docstrings updated to match the loop-based reality. --- src/tmuxp/workspace/builder.py | 54 ++++++++++++++++++++++++++++++++- tests/workspace/test_builder.py | 45 +++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 94c2f1d801..8ff3bad0b0 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -9,6 +9,7 @@ import typing as t from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.options import handle_option_error from libtmux.pane import Pane from libtmux.server import Server from libtmux.session import Session @@ -19,7 +20,7 @@ from tmuxp.util import get_current_pane, run_before_script if t.TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterator, Mapping logger = logging.getLogger(__name__) @@ -407,6 +408,57 @@ def session_exists(self, session_name: str) -> bool: return False return True + def _bulk_set_options( + self, + items: Mapping[str, int | str | bool], + *, + target: str | None, + scope_flag: str, + ) -> None: + """Apply ``set-option`` for each (key, value) pair. + + Mirrors :meth:`libtmux.options.OptionsMixin.set_option`'s + ``True/False -> "on"/"off"`` convention so behaviour matches a + plain loop of ``set_option`` calls. Errors propagate as the + same ``OptionError`` subclasses ``handle_option_error`` + produces. + + Currently issues one ``set-option`` round-trip per item. The + helper's API (mapping + scope flag + optional target) is + deliberately batch-shaped so the body can swap to a single + pipelined dispatch (e.g. ``Server.batch()``) once libtmux + exposes one, without touching the call sites in ``build`` and + ``iter_create_windows``. + + Parameters + ---------- + items : mapping + Option name -> value pairs. + target : str, optional + Target identifier (session_id / window_id) for ``-t``; + pass ``None`` for global options where ``-g`` already + names the scope. + scope_flag : str + ``"-s"``, ``"-g"``, or ``"-w"``. Selects the option scope. + """ + if not items: + return + server = self.server + assert server is not None + for key, raw_val in items.items(): + if raw_val is True: + val: int | str = "on" + elif raw_val is False: + val = "off" + else: + val = raw_val + if target is not None: + cmd = server.cmd("set-option", scope_flag, "-t", target, key, val) + else: + cmd = server.cmd("set-option", scope_flag, key, val) + if cmd.stderr: + handle_option_error(cmd.stderr[0]) + def build(self, session: Session | None = None, append: bool = False) -> None: """Build tmux workspace in session. diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index da95168f46..94569ca8bb 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -359,6 +359,51 @@ def f() -> bool: ), "Synchronized command did not execute properly" +def test_bulk_set_options_propagates_unknown_option_error( + session: Session, +) -> None: + """Bad options surface as ``OptionError``.""" + workspace = { + "session_name": "bulk-set-options-bad", + "options": {"this-option-does-not-exist": "value"}, + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + with pytest.raises(libtmux.exc.OptionError): + builder.build(session=session) + + +def test_bulk_set_options_applies_session_window_and_options_after( + session: Session, +) -> None: + """Session, window, and options_after loops all land via the helper.""" + workspace = { + "session_name": "bulk-set-options-good", + "options": {"default-shell": "/bin/sh"}, + "global_options": {"repeat-time": 491}, + "windows": [ + { + "window_name": "main", + "options": {"main-pane-height": 7}, + "options_after": {"synchronize-panes": "on"}, + "panes": [{"shell_command": []}, {"shell_command": []}], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + sess = builder.session + win = sess.active_window + default_shell = sess.show_option("default-shell") + assert isinstance(default_shell, str) + assert "/bin/sh" in default_shell + assert sess.show_option("repeat-time", global_=True) == 491 + assert win.show_option("main-pane-height") == 7 + sync = win.cmd("show-option", "-v", "synchronize-panes").stdout + assert sync == ["on"] + + def test_window_shell( session: Session, ) -> None: From 9d4e4f3f742a158c632283217ba89fa010dbbf73 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 06:53:24 -0500 Subject: [PATCH 3/3] workspace(refactor[builder]) route set-option loops through _bulk_set_options why: Workspace loading dispatches one tmux ``set-option`` per loop iteration across four hot loops (session ``options``, ``global_options``, per-window ``options``, ``options_after``). The helper that ``f4c95faa`` introduced provides a single, batch-shaped entry point for these calls; this commit moves the existing call sites onto it. Today the helper is a plain loop so this is a pure refactor (same N round-trips, same observable behaviour). Once libtmux exposes a pipelined ``Server.batch()`` the helper body can swap internally and the call sites automatically benefit without further changes here. what: - Replace the session ``options`` loop in ``build`` with ``_bulk_set_options(..., scope_flag="-s")`` targeting ``session.session_id``. - Replace the ``global_options`` loop in ``build`` with ``_bulk_set_options(..., target=None, scope_flag="-g")``. - Replace the per-window ``options`` loop in ``iter_create_windows`` with ``_bulk_set_options(..., scope_flag="-w")`` targeting ``window.window_id``. - Replace the ``options_after`` loop in ``_apply_options_after`` (or equivalent post-build hook) with ``_bulk_set_options(..., scope_flag="-w")`` targeting ``window.window_id``. Salvaged from libtmux-protocol@2390ce3b. The original commit described the change as a perf win; against current libtmux 0.56.0 the helper body issues N round-trips per call site (no ``Server.batch()`` yet), so this commit is documented as a refactor and the perf framing is deferred to whenever libtmux ships a batching API. --- src/tmuxp/workspace/builder.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 8ff3bad0b0..2100a95f72 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -579,12 +579,18 @@ def build(self, session: Session | None = None, append: bool = False) -> None: self.on_build_event({"event": "before_script_done"}) if "options" in self.session_config: - for option, value in self.session_config["options"].items(): - self.session.set_option(option, value) + self._bulk_set_options( + self.session_config["options"], + target=self.session.session_id, + scope_flag="-s", + ) if "global_options" in self.session_config: - for option, value in self.session_config["global_options"].items(): - self.session.set_option(option, value, global_=True) + self._bulk_set_options( + self.session_config["global_options"], + target=None, + scope_flag="-g", + ) if "environment" in self.session_config: for option, value in self.session_config["environment"].items(): @@ -723,8 +729,11 @@ def iter_create_windows( window_config["options"], dict, ): - for key, val in window_config["options"].items(): - window.set_option(key, val) + self._bulk_set_options( + window_config["options"], + target=window.window_id, + scope_flag="-w", + ) if window_config.get("focus"): window.select() @@ -893,8 +902,11 @@ def config_after_window( window_config["options_after"], dict, ): - for key, val in window_config["options_after"].items(): - window.set_option(key, val) + self._bulk_set_options( + window_config["options_after"], + target=window.window_id, + scope_flag="-w", + ) def find_current_attached_session(self) -> Session: """Return current attached session."""