Skip to content

Commit 294f659

Browse files
authored
Fix race condition in new_session() by avoiding list-sessions query (#625)
new_session() previously used a two-step approach: create the session with `tmux new-session -P -F#{session_id}`, then immediately query `list-sessions` to hydrate the returned Session object. In certain environments (PyInstaller-bundled binaries, Python 3.13+, Docker), the session was not yet visible to list-sessions, raising TmuxObjectDoesNotExist. The fix eliminates the second query entirely. new_session() now passes the full Obj format string to `new-session -P` and constructs the Session directly from that output — one subprocess call, no race. Changes: - Server.new_session(): use full -F format string; parse -P output directly into Session without a follow-up list-sessions query - neo.py: extract get_output_format() (@functools.cache) and parse_output() helpers; refactor fetch_objs() to use them; switch parse_output() to zip(strict=True) after stripping the trailing separator element for fail-fast mismatch detection - server.py: wrap TMUX env var removal in try/finally to guarantee restoration on exception - tests: add test_new_session_returns_populated_session() asserting session_id, session_name, window_id, and pane_id are all populated Fixes #624. Thank you @neubig!
2 parents e59d417 + fb7f898 commit 294f659

4 files changed

Lines changed: 173 additions & 55 deletions

File tree

CHANGES

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,26 @@ $ uvx --from 'libtmux' --prerelease allow python
3636
_Notes on the upcoming release will go here._
3737
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
3838

39+
### Bug fixes
40+
41+
#### Fix race condition in new_session() (#625)
42+
43+
Fixed {exc}`~libtmux.exc.TmuxObjectDoesNotExist` raised by
44+
{meth}`~libtmux.Server.new_session` in some environments (e.g. PyInstaller-bundled
45+
binaries, Python 3.13+, Docker containers).
46+
47+
Previously, `new_session()` ran `tmux new-session -P -F#{session_id}` to create the
48+
session, then immediately issued a separate `list-sessions` query to hydrate the
49+
{class}`~libtmux.Session` object. In certain environments the session was not yet
50+
visible to `list-sessions`, causing a spurious failure.
51+
52+
The fix expands the `-F` format string to include all session fields and parses the
53+
`new-session -P` output directly into the returned `Session`, eliminating the
54+
follow-up query entirely. This is also one fewer subprocess call per session
55+
creation.
56+
57+
Closes: #624. Thank you @neubig!
58+
3959
### Development
4060

4161
#### Makefile -> Justfile (#617)

src/libtmux/neo.py

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import dataclasses
6+
import functools
67
import logging
78
import typing as t
89
from collections.abc import Iterable
@@ -177,21 +178,122 @@ def _refresh(
177178
setattr(self, k, v)
178179

179180

181+
@functools.cache
182+
def get_output_format() -> tuple[tuple[str, ...], str]:
183+
"""Return field names and tmux format string for all Obj fields.
184+
185+
Excludes the ``server`` field, which is a Python object reference
186+
rather than a tmux format variable.
187+
188+
Returns
189+
-------
190+
tuple[tuple[str, ...], str]
191+
A tuple of (field_names, tmux_format_string).
192+
193+
Examples
194+
--------
195+
>>> from libtmux.neo import get_output_format
196+
>>> fields, fmt = get_output_format()
197+
>>> 'session_id' in fields
198+
True
199+
>>> 'server' in fields
200+
False
201+
"""
202+
# Exclude 'server' - it's a Python object, not a tmux format variable
203+
formats = tuple(f for f in Obj.__dataclass_fields__ if f != "server")
204+
tmux_formats = [f"#{{{f}}}{FORMAT_SEPARATOR}" for f in formats]
205+
return formats, "".join(tmux_formats)
206+
207+
208+
def parse_output(output: str) -> OutputRaw:
209+
"""Parse tmux output formatted with get_output_format() into a dict.
210+
211+
Parameters
212+
----------
213+
output : str
214+
Raw tmux output produced with the format string from
215+
:func:`get_output_format`.
216+
217+
Returns
218+
-------
219+
OutputRaw
220+
A dict mapping field names to non-empty string values.
221+
222+
Examples
223+
--------
224+
>>> from libtmux.neo import get_output_format, parse_output
225+
>>> from libtmux.formats import FORMAT_SEPARATOR
226+
>>> fields, fmt = get_output_format()
227+
>>> values = [''] * len(fields)
228+
>>> values[fields.index('session_id')] = '$1'
229+
>>> result = parse_output(FORMAT_SEPARATOR.join(values) + FORMAT_SEPARATOR)
230+
>>> result['session_id']
231+
'$1'
232+
>>> 'buffer_sample' in result
233+
False
234+
"""
235+
formats, _ = get_output_format()
236+
values = output.split(FORMAT_SEPARATOR)
237+
238+
# Remove the trailing empty string from the split
239+
if values and values[-1] == "":
240+
values = values[:-1]
241+
242+
formatter = dict(zip(formats, values, strict=True))
243+
return {k: v for k, v in formatter.items() if v}
244+
245+
180246
def fetch_objs(
181247
server: Server,
182248
list_cmd: ListCmd,
183249
list_extra_args: ListExtraArgs = None,
184250
) -> OutputsRaw:
185-
"""Fetch a listing of raw data from a tmux command."""
186-
formats = list(Obj.__dataclass_fields__.keys())
251+
"""Fetch a listing of raw data from a tmux command.
252+
253+
Runs a tmux list command (e.g. ``list-sessions``) with the format string
254+
from :func:`get_output_format` and parses each line of output into a dict.
255+
256+
Parameters
257+
----------
258+
server : :class:`~libtmux.server.Server`
259+
The tmux server to query.
260+
list_cmd : ListCmd
261+
The tmux list command to run, e.g. ``"list-sessions"``,
262+
``"list-windows"``, or ``"list-panes"``.
263+
list_extra_args : ListExtraArgs, optional
264+
Extra arguments appended to the tmux command (e.g. ``("-a",)``
265+
for all windows/panes, or ``["-t", session_id]`` to filter).
266+
267+
Returns
268+
-------
269+
OutputsRaw
270+
A list of dicts, each mapping tmux format field names to their
271+
non-empty string values.
272+
273+
Raises
274+
------
275+
:exc:`~libtmux.exc.LibTmuxException`
276+
If the tmux command writes to stderr.
277+
278+
Examples
279+
--------
280+
>>> from libtmux.neo import fetch_objs
281+
>>> objs = fetch_objs(server=server, list_cmd="list-sessions")
282+
>>> isinstance(objs, list)
283+
True
284+
>>> isinstance(objs[0], dict)
285+
True
286+
>>> 'session_id' in objs[0]
287+
True
288+
"""
289+
_fields, format_string = get_output_format()
187290

188291
cmd_args: list[str | int] = []
189292

190293
if server.socket_name:
191294
cmd_args.insert(0, f"-L{server.socket_name}")
192295
if server.socket_path:
193296
cmd_args.insert(0, f"-S{server.socket_path}")
194-
tmux_formats = [f"#{{{f}}}{FORMAT_SEPARATOR}" for f in formats]
195297

196298
tmux_cmds = [
197299
*cmd_args,
@@ -201,22 +303,14 @@ def fetch_objs(
201303
if list_extra_args is not None and isinstance(list_extra_args, Iterable):
202304
tmux_cmds.extend(list(list_extra_args))
203305

204-
tmux_cmds.append("-F{}".format("".join(tmux_formats)))
306+
tmux_cmds.append(f"-F{format_string}")
205307

206308
proc = tmux_cmd(*tmux_cmds) # output
207309

208310
if proc.stderr:
209311
raise exc.LibTmuxException(proc.stderr)
210312

211-
obj_output = proc.stdout
212-
213-
obj_formatters = [
214-
dict(zip(formats, formatter.split(FORMAT_SEPARATOR), strict=False))
215-
for formatter in obj_output
216-
]
217-
218-
# Filter empty values
219-
return [{k: v for k, v in formatter.items() if v} for formatter in obj_formatters]
313+
return [parse_output(line) for line in proc.stdout]
220314

221315

222316
def fetch_obj(

src/libtmux/server.py

Lines changed: 37 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@
1414
import subprocess
1515
import typing as t
1616

17-
from libtmux import exc, formats
17+
from libtmux import exc
1818
from libtmux._internal.query_list import QueryList
1919
from libtmux.common import tmux_cmd
2020
from libtmux.constants import OptionScope
2121
from libtmux.hooks import HooksMixin
22-
from libtmux.neo import fetch_objs
22+
from libtmux.neo import fetch_objs, get_output_format, parse_output
2323
from libtmux.pane import Pane
2424
from libtmux.session import Session
2525
from libtmux.window import Window
@@ -539,59 +539,54 @@ def new_session(
539539
if env:
540540
del os.environ["TMUX"]
541541

542-
tmux_args: tuple[str | int, ...] = (
543-
"-P",
544-
"-F#{session_id}", # output
545-
)
542+
try:
543+
_fields, format_string = get_output_format()
546544

547-
if session_name is not None:
548-
tmux_args += (f"-s{session_name}",)
545+
tmux_args: tuple[str | int, ...] = (
546+
"-P",
547+
f"-F{format_string}",
548+
)
549549

550-
if not attach:
551-
tmux_args += ("-d",)
550+
if session_name is not None:
551+
tmux_args += (f"-s{session_name}",)
552552

553-
if start_directory:
554-
start_directory = pathlib.Path(start_directory).expanduser()
555-
tmux_args += ("-c", str(start_directory))
553+
if not attach:
554+
tmux_args += ("-d",)
556555

557-
if window_name:
558-
tmux_args += ("-n", window_name)
556+
if start_directory:
557+
start_directory = pathlib.Path(start_directory).expanduser()
558+
tmux_args += ("-c", str(start_directory))
559559

560-
if x is not None:
561-
tmux_args += ("-x", x)
560+
if window_name:
561+
tmux_args += ("-n", window_name)
562562

563-
if y is not None:
564-
tmux_args += ("-y", y)
563+
if x is not None:
564+
tmux_args += ("-x", x)
565565

566-
if environment:
567-
for k, v in environment.items():
568-
tmux_args += (f"-e{k}={v}",)
566+
if y is not None:
567+
tmux_args += ("-y", y)
569568

570-
if window_command:
571-
tmux_args += (window_command,)
569+
if environment:
570+
for k, v in environment.items():
571+
tmux_args += (f"-e{k}={v}",)
572572

573-
proc = self.cmd("new-session", *tmux_args)
573+
if window_command:
574+
tmux_args += (window_command,)
574575

575-
if proc.stderr:
576-
raise exc.LibTmuxException(proc.stderr)
576+
proc = self.cmd("new-session", *tmux_args)
577577

578-
session_stdout = proc.stdout[0]
578+
if proc.stderr:
579+
raise exc.LibTmuxException(proc.stderr)
579580

580-
if env:
581-
os.environ["TMUX"] = env
582-
583-
session_formatters = dict(
584-
zip(
585-
["session_id"],
586-
session_stdout.split(formats.FORMAT_SEPARATOR),
587-
strict=False,
588-
),
589-
)
581+
session_stdout = proc.stdout[0]
590582

591-
return Session.from_session_id(
592-
server=self,
593-
session_id=session_formatters["session_id"],
594-
)
583+
finally:
584+
if env:
585+
os.environ["TMUX"] = env
586+
587+
session_data = parse_output(session_stdout)
588+
589+
return Session(server=self, **session_data)
595590

596591
#
597592
# Relations

tests/test_server.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ def test_new_session(server: Server) -> None:
104104
assert server.has_session("test_new_session")
105105

106106

107+
def test_new_session_returns_populated_session(server: Server) -> None:
108+
"""Server.new_session returns Session populated from -P output."""
109+
session = server.new_session(session_name="test_populated")
110+
assert session.session_id is not None
111+
assert session.session_name == "test_populated"
112+
assert session.window_id is not None
113+
assert session.pane_id is not None
114+
115+
107116
def test_new_session_no_name(server: Server) -> None:
108117
"""Server.new_session works with no name."""
109118
first_session = server.new_session()

0 commit comments

Comments
 (0)