From 5cd3ccfe1c07c0be52478553b33c5f9edf45d3a9 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 5 Feb 2026 05:24:34 +0000 Subject: [PATCH 1/7] 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 16d4f05141116bf8bbbcf41c9f0347b634338a29 Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Fri, 6 Feb 2026 07:16:58 -0500 Subject: [PATCH 2/7] 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 f70f14b162596eeab4b690c8a8f4561082488dc9 Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Fri, 6 Feb 2026 07:17:10 -0500 Subject: [PATCH 3/7] 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 27d9ad7591943bf6724cc0c4e1d07f307a199be3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 9 Feb 2026 08:17:29 -0600 Subject: [PATCH 4/7] 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 6530bfc275d43ac5deebdee856249b17b7ff21d3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 9 Feb 2026 10:00:19 -0600 Subject: [PATCH 5/7] 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() From 0f389cab6192fddfef886003866c4f7d96a52462 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 18 Feb 2026 13:36:38 -0600 Subject: [PATCH 6/7] fix(server): robust output parsing and env var safety why: - parse_output used strict=False which could silently hide truncated output - TMUX env var modification was not exception-safe - new test didn't verify all populated fields what: - Update parse_output to use strict=True after handling trailing separator - Wrap TMUX env var restoration in try/finally block - Add assertions for window_id and pane_id in new session test --- src/libtmux/neo.py | 8 +++++- src/libtmux/server.py | 60 ++++++++++++++++++++++--------------------- tests/test_server.py | 2 ++ 3 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 30ac475b9..ce0233386 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -233,7 +233,13 @@ def parse_output(output: str) -> OutputRaw: False """ formats, _ = get_output_format() - formatter = dict(zip(formats, output.split(FORMAT_SEPARATOR), strict=False)) + values = output.split(FORMAT_SEPARATOR) + + # Remove the trailing empty string from the split + if values and values[-1] == "": + values = values[:-1] + + formatter = dict(zip(formats, values, strict=True)) return {k: v for k, v in formatter.items() if v} diff --git a/src/libtmux/server.py b/src/libtmux/server.py index edb52067e..e892d4e66 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -539,48 +539,50 @@ def new_session( if env: del os.environ["TMUX"] - _fields, format_string = get_output_format() + try: + _fields, format_string = get_output_format() - tmux_args: tuple[str | int, ...] = ( - "-P", - f"-F{format_string}", - ) + tmux_args: tuple[str | int, ...] = ( + "-P", + f"-F{format_string}", + ) - if session_name is not None: - tmux_args += (f"-s{session_name}",) + if session_name is not None: + tmux_args += (f"-s{session_name}",) - if not attach: - tmux_args += ("-d",) + if not attach: + tmux_args += ("-d",) - if start_directory: - start_directory = pathlib.Path(start_directory).expanduser() - tmux_args += ("-c", str(start_directory)) + if start_directory: + start_directory = pathlib.Path(start_directory).expanduser() + tmux_args += ("-c", str(start_directory)) - if window_name: - tmux_args += ("-n", window_name) + if window_name: + tmux_args += ("-n", window_name) - if x is not None: - tmux_args += ("-x", x) + if x is not None: + tmux_args += ("-x", x) - if y is not None: - tmux_args += ("-y", y) + if y is not None: + tmux_args += ("-y", y) - if environment: - for k, v in environment.items(): - tmux_args += (f"-e{k}={v}",) + if environment: + for k, v in environment.items(): + tmux_args += (f"-e{k}={v}",) - if window_command: - tmux_args += (window_command,) + if window_command: + tmux_args += (window_command,) - proc = self.cmd("new-session", *tmux_args) + proc = self.cmd("new-session", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) - session_stdout = proc.stdout[0] + session_stdout = proc.stdout[0] - if env: - os.environ["TMUX"] = env + finally: + if env: + os.environ["TMUX"] = env session_data = parse_output(session_stdout) diff --git a/tests/test_server.py b/tests/test_server.py index cb9d83a9c..21fa04445 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -109,6 +109,8 @@ def test_new_session_returns_populated_session(server: Server) -> None: session = server.new_session(session_name="test_populated") assert session.session_id is not None assert session.session_name == "test_populated" + assert session.window_id is not None + assert session.pane_id is not None def test_new_session_no_name(server: Server) -> None: From fb7f898b34a6308655895502cb795f8527e72f98 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 18 Feb 2026 18:25:25 -0600 Subject: [PATCH 7/7] docs(CHANGES): Add bug fix entry for new_session() race condition (#625) why: Document the fix for TmuxObjectDoesNotExist race condition in new_session() for the upcoming 0.54.x release. what: - Add Bug fixes section to 0.54.x changelog - Reference issue #624 and PR #625 --- CHANGES | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGES b/CHANGES index 80938b3f8..4f7182a3a 100644 --- a/CHANGES +++ b/CHANGES @@ -36,6 +36,26 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### Bug fixes + +#### Fix race condition in new_session() (#625) + +Fixed {exc}`~libtmux.exc.TmuxObjectDoesNotExist` raised by +{meth}`~libtmux.Server.new_session` in some environments (e.g. PyInstaller-bundled +binaries, Python 3.13+, Docker containers). + +Previously, `new_session()` ran `tmux new-session -P -F#{session_id}` to create the +session, then immediately issued a separate `list-sessions` query to hydrate the +{class}`~libtmux.Session` object. In certain environments the session was not yet +visible to `list-sessions`, causing a spurious failure. + +The fix expands the `-F` format string to include all session fields and parses the +`new-session -P` output directly into the returned `Session`, eliminating the +follow-up query entirely. This is also one fewer subprocess call per session +creation. + +Closes: #624. Thank you @neubig! + ### Development #### Makefile -> Justfile (#617)