From 3b27dc9a9504eeebbf400280fa6182b896e87dc4 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 5 Feb 2026 05:24:34 +0000 Subject: [PATCH 1/5] Fix race condition in new_session() by avoiding list-sessions query Previously, new_session() would run 'tmux new-session -P -F#{session_id}' then immediately query 'tmux list-sessions' to fetch full session data. This created a race condition in PyInstaller + Python 3.13+ + Docker environments where list-sessions might not see the newly created session. The fix expands the -F format string to include all Obj fields, parsing the output directly into a Session object without a separate query. Co-authored-by: openhands --- src/libtmux/neo.py | 16 ++++++++++++++++ src/libtmux/server.py | 19 ++++++------------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 932f969e1..d94a3a753 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -177,6 +177,22 @@ def _refresh( setattr(self, k, v) +def get_output_format() -> tuple[list[str], str]: + """Return field names and tmux format string for all Obj fields.""" + # Exclude 'server' - it's a Python object, not a tmux format variable + formats = [f for f in Obj.__dataclass_fields__.keys() if f != "server"] + tmux_formats = [f"#{{{f}}}{FORMAT_SEPARATOR}" for f in formats] + return formats, "".join(tmux_formats) + + +def parse_output(output: str) -> OutputRaw: + """Parse tmux output formatted with get_output_format() into a dict.""" + # Exclude 'server' - it's a Python object, not a tmux format variable + formats = [f for f in Obj.__dataclass_fields__.keys() if f != "server"] + formatter = dict(zip(formats, output.split(FORMAT_SEPARATOR), strict=False)) + return {k: v for k, v in formatter.items() if v} + + def fetch_objs( server: Server, list_cmd: ListCmd, diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 71f9f84a7..088a66d25 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -19,7 +19,7 @@ from libtmux.common import tmux_cmd from libtmux.constants import OptionScope from libtmux.hooks import HooksMixin -from libtmux.neo import fetch_objs +from libtmux.neo import fetch_objs, get_output_format, parse_output from libtmux.pane import Pane from libtmux.session import Session from libtmux.window import Window @@ -539,9 +539,11 @@ def new_session( if env: del os.environ["TMUX"] + _fields, format_string = get_output_format() + tmux_args: tuple[str | int, ...] = ( "-P", - "-F#{session_id}", # output + f"-F{format_string}", ) if session_name is not None: @@ -580,18 +582,9 @@ def new_session( if env: os.environ["TMUX"] = env - session_formatters = dict( - zip( - ["session_id"], - session_stdout.split(formats.FORMAT_SEPARATOR), - strict=False, - ), - ) + session_data = parse_output(session_stdout) - return Session.from_session_id( - server=self, - session_id=session_formatters["session_id"], - ) + return Session(server=self, **session_data) # # Relations From 4367916f6e8d99aebf51330746f705a2b181b14f Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Fri, 6 Feb 2026 07:16:58 -0500 Subject: [PATCH 2/5] Apply suggestion from @tony Co-authored-by: Tony Narlock --- src/libtmux/neo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index d94a3a753..b0457b108 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -180,7 +180,7 @@ def _refresh( def get_output_format() -> tuple[list[str], str]: """Return field names and tmux format string for all Obj fields.""" # Exclude 'server' - it's a Python object, not a tmux format variable - formats = [f for f in Obj.__dataclass_fields__.keys() if f != "server"] + formats = [f for f in Obj.__dataclass_fields__ if f != "server"] tmux_formats = [f"#{{{f}}}{FORMAT_SEPARATOR}" for f in formats] return formats, "".join(tmux_formats) From 058ee6da6ed61d257d81bfbf7fce03c50941506c Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Fri, 6 Feb 2026 07:17:10 -0500 Subject: [PATCH 3/5] Update src/libtmux/neo.py Co-authored-by: Tony Narlock --- src/libtmux/neo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index b0457b108..33402c91b 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -188,7 +188,7 @@ def get_output_format() -> tuple[list[str], str]: def parse_output(output: str) -> OutputRaw: """Parse tmux output formatted with get_output_format() into a dict.""" # Exclude 'server' - it's a Python object, not a tmux format variable - formats = [f for f in Obj.__dataclass_fields__.keys() if f != "server"] + formats = [f for f in Obj.__dataclass_fields__ if f != "server"] formatter = dict(zip(formats, output.split(FORMAT_SEPARATOR), strict=False)) return {k: v for k, v in formatter.items() if v} From 6c3beb89f72b5917231d4dfeaec41b067edb8a96 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 9 Feb 2026 08:17:29 -0600 Subject: [PATCH 4/5] neo(refactor): Add docstrings, dedup helpers, cache get_output_format why: Graham's race-condition fix introduced get_output_format() and parse_output() in neo.py; fetch_objs() had duplicate logic doing the same thing. Functions also lacked the NumPy-style docstrings and doctests required by project standards. what: - Add NumPy-style docstrings with doctests to get_output_format(), parse_output(), and fetch_objs() - Make parse_output() call get_output_format() instead of duplicating the field-list computation - Refactor fetch_objs() to use get_output_format() and parse_output() instead of inline format/parse logic - Cache get_output_format() with @functools.cache (fields are static) - Remove unused formats import from server.py - Simplify format_string = get_output_format()[1] in new_session() --- src/libtmux/neo.py | 110 ++++++++++++++++++++++++++++++++++-------- src/libtmux/server.py | 4 +- 2 files changed, 93 insertions(+), 21 deletions(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 33402c91b..30ac475b9 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses +import functools import logging import typing as t from collections.abc import Iterable @@ -177,18 +178,61 @@ def _refresh( setattr(self, k, v) -def get_output_format() -> tuple[list[str], str]: - """Return field names and tmux format string for all Obj fields.""" +@functools.cache +def get_output_format() -> tuple[tuple[str, ...], str]: + """Return field names and tmux format string for all Obj fields. + + Excludes the ``server`` field, which is a Python object reference + rather than a tmux format variable. + + Returns + ------- + tuple[tuple[str, ...], str] + A tuple of (field_names, tmux_format_string). + + Examples + -------- + >>> from libtmux.neo import get_output_format + >>> fields, fmt = get_output_format() + >>> 'session_id' in fields + True + >>> 'server' in fields + False + """ # Exclude 'server' - it's a Python object, not a tmux format variable - formats = [f for f in Obj.__dataclass_fields__ if f != "server"] + formats = tuple(f for f in Obj.__dataclass_fields__ if f != "server") tmux_formats = [f"#{{{f}}}{FORMAT_SEPARATOR}" for f in formats] return formats, "".join(tmux_formats) def parse_output(output: str) -> OutputRaw: - """Parse tmux output formatted with get_output_format() into a dict.""" - # Exclude 'server' - it's a Python object, not a tmux format variable - formats = [f for f in Obj.__dataclass_fields__ if f != "server"] + """Parse tmux output formatted with get_output_format() into a dict. + + Parameters + ---------- + output : str + Raw tmux output produced with the format string from + :func:`get_output_format`. + + Returns + ------- + OutputRaw + A dict mapping field names to non-empty string values. + + Examples + -------- + >>> from libtmux.neo import get_output_format, parse_output + >>> from libtmux.formats import FORMAT_SEPARATOR + >>> fields, fmt = get_output_format() + >>> values = [''] * len(fields) + >>> values[fields.index('session_id')] = '$1' + >>> result = parse_output(FORMAT_SEPARATOR.join(values) + FORMAT_SEPARATOR) + >>> result['session_id'] + '$1' + >>> 'buffer_sample' in result + False + """ + formats, _ = get_output_format() formatter = dict(zip(formats, output.split(FORMAT_SEPARATOR), strict=False)) return {k: v for k, v in formatter.items() if v} @@ -198,8 +242,45 @@ def fetch_objs( list_cmd: ListCmd, list_extra_args: ListExtraArgs = None, ) -> OutputsRaw: - """Fetch a listing of raw data from a tmux command.""" - formats = list(Obj.__dataclass_fields__.keys()) + """Fetch a listing of raw data from a tmux command. + + Runs a tmux list command (e.g. ``list-sessions``) with the format string + from :func:`get_output_format` and parses each line of output into a dict. + + Parameters + ---------- + server : :class:`~libtmux.server.Server` + The tmux server to query. + list_cmd : ListCmd + The tmux list command to run, e.g. ``"list-sessions"``, + ``"list-windows"``, or ``"list-panes"``. + list_extra_args : ListExtraArgs, optional + Extra arguments appended to the tmux command (e.g. ``("-a",)`` + for all windows/panes, or ``["-t", session_id]`` to filter). + + Returns + ------- + OutputsRaw + A list of dicts, each mapping tmux format field names to their + non-empty string values. + + Raises + ------ + :exc:`~libtmux.exc.LibTmuxException` + If the tmux command writes to stderr. + + Examples + -------- + >>> from libtmux.neo import fetch_objs + >>> objs = fetch_objs(server=server, list_cmd="list-sessions") + >>> isinstance(objs, list) + True + >>> isinstance(objs[0], dict) + True + >>> 'session_id' in objs[0] + True + """ + _fields, format_string = get_output_format() cmd_args: list[str | int] = [] @@ -207,7 +288,6 @@ def fetch_objs( cmd_args.insert(0, f"-L{server.socket_name}") if server.socket_path: cmd_args.insert(0, f"-S{server.socket_path}") - tmux_formats = [f"#{{{f}}}{FORMAT_SEPARATOR}" for f in formats] tmux_cmds = [ *cmd_args, @@ -217,22 +297,14 @@ def fetch_objs( if list_extra_args is not None and isinstance(list_extra_args, Iterable): tmux_cmds.extend(list(list_extra_args)) - tmux_cmds.append("-F{}".format("".join(tmux_formats))) + tmux_cmds.append(f"-F{format_string}") proc = tmux_cmd(*tmux_cmds) # output if proc.stderr: raise exc.LibTmuxException(proc.stderr) - obj_output = proc.stdout - - obj_formatters = [ - dict(zip(formats, formatter.split(FORMAT_SEPARATOR), strict=False)) - for formatter in obj_output - ] - - # Filter empty values - return [{k: v for k, v in formatter.items() if v} for formatter in obj_formatters] + return [parse_output(line) for line in proc.stdout] def fetch_obj( diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 088a66d25..ecb14dbfe 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -14,7 +14,7 @@ import subprocess import typing as t -from libtmux import exc, formats +from libtmux import exc from libtmux._internal.query_list import QueryList from libtmux.common import tmux_cmd from libtmux.constants import OptionScope @@ -539,7 +539,7 @@ def new_session( if env: del os.environ["TMUX"] - _fields, format_string = get_output_format() + format_string = get_output_format()[1] tmux_args: tuple[str | int, ...] = ( "-P", From 6d202e082030efb04074b9231c152510386aebca Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 9 Feb 2026 10:00:19 -0600 Subject: [PATCH 5/5] Server(test[new_session]): Verify populated Session from -P output why: The race condition fix changed new_session() to construct Session from -P output instead of a follow-up list-sessions query, but had no dedicated test verifying session_id and session_name are populated. what: - Add test asserting session_id is not None and session_name matches --- src/libtmux/server.py | 2 +- tests/test_server.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index ecb14dbfe..edb52067e 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -539,7 +539,7 @@ def new_session( if env: del os.environ["TMUX"] - format_string = get_output_format()[1] + _fields, format_string = get_output_format() tmux_args: tuple[str | int, ...] = ( "-P", diff --git a/tests/test_server.py b/tests/test_server.py index 9b85d279c..cb9d83a9c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -104,6 +104,13 @@ def test_new_session(server: Server) -> None: assert server.has_session("test_new_session") +def test_new_session_returns_populated_session(server: Server) -> None: + """Server.new_session returns Session populated from -P output.""" + session = server.new_session(session_name="test_populated") + assert session.session_id is not None + assert session.session_name == "test_populated" + + def test_new_session_no_name(server: Server) -> None: """Server.new_session works with no name.""" first_session = server.new_session()