Skip to content

Commit cb8384e

Browse files
authored
Subprocess(fix[encoding]): Enforce UTF-8 decoding for tmux output (#679)
tmux mandates UTF-8 since 2015, but `subprocess.Popen(text=True)` without an explicit `encoding` falls back to `locale.getencoding()`. On non-UTF-8 locales the FORMAT_SEPARATOR (U+241E) is decoded as three separate code points, breaking `parse_output()` and causing all list accessors to return empty results. - **`tmux_cmd`**: add `encoding="utf-8"` to `subprocess.Popen` in `tmux_cmd.__init__` - **`ControlMode`**: same fix for the control-protocol subprocess - **Regression tests**: both paths validated under `LC_CTYPE=C` to confirm FORMAT_SEPARATOR survives the round-trip Fixes: #678 See also: tmux-python/tmuxp#1044
2 parents 5089f31 + fc5e6c4 commit cb8384e

5 files changed

Lines changed: 99 additions & 0 deletions

File tree

CHANGES

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ $ uvx --from 'libtmux' --prerelease allow python
4545
_Notes on the upcoming release will go here._
4646
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
4747

48+
### Fixes
49+
50+
#### Subprocess encoding on non-UTF-8 locales (#679)
51+
52+
{class}`~libtmux.common.tmux_cmd` and {class}`~libtmux._internal.control_mode.ControlMode`
53+
now pass `encoding="utf-8"` to `subprocess.Popen`, ensuring tmux output
54+
is decoded correctly regardless of the system locale. Previously, on
55+
non-UTF-8 locales, the {data}`~libtmux.formats.FORMAT_SEPARATOR` character
56+
(U+241E) was corrupted during decoding, causing list accessors
57+
({attr}`~libtmux.Server.sessions`, etc.) to return empty results.
58+
4859
## libtmux 0.57.1 (2026-05-18)
4960

5061
Restores the "lenient-by-default" behavior for

src/libtmux/_internal/control_mode.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def __enter__(self) -> ControlMode:
8686
stdout=subprocess.PIPE,
8787
stderr=subprocess.PIPE,
8888
text=True,
89+
encoding="utf-8",
8990
)
9091
finally:
9192
# subprocess owns read_fd now

src/libtmux/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None:
333333
stdout=subprocess.PIPE,
334334
stderr=subprocess.PIPE,
335335
text=True,
336+
encoding="utf-8",
336337
errors="backslashreplace",
337338
)
338339
stdout, stderr = self.process.communicate()

tests/test_common.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import locale
56
import logging
67
import re
78
import sys
@@ -713,3 +714,51 @@ def test_raise_if_stderr_str_shape_exact(session: libtmux.Session) -> None:
713714
assert str(excinfo.value) == "last-window: no last window"
714715
assert excinfo.value.args == ("no last window",)
715716
assert excinfo.value.subcommand == "last-window"
717+
718+
719+
@pytest.mark.skipif(
720+
sys.flags.utf8_mode != 0,
721+
reason="PYTHONUTF8 mode forces UTF-8, masking the locale bug",
722+
)
723+
def test_tmux_cmd_format_separator_survives_non_utf8_locale(
724+
session: Session,
725+
) -> None:
726+
"""FORMAT_SEPARATOR must survive a non-UTF-8 locale round-trip through tmux_cmd.
727+
728+
Regression test for the encoding bug introduced in commit 1a5e69a2
729+
(``tmux_cmd: Remove console_to_str(), use text=True``). When
730+
``subprocess.Popen`` receives ``text=True`` without an explicit
731+
``encoding="utf-8"``, CPython falls back to the process locale encoding. On
732+
a ``C`` locale the FORMAT_SEPARATOR character U+241E (UTF-8 bytes
733+
``e2 90 9e``) is decoded as escaped bytes, corrupting every
734+
``parse_output()`` call downstream.
735+
736+
This test guards the explicit ``encoding="utf-8"`` passed to
737+
``subprocess.Popen`` in ``tmux_cmd.__init__``.
738+
"""
739+
from libtmux.formats import FORMAT_SEPARATOR
740+
from libtmux.neo import get_output_format, parse_output
741+
742+
server = session.server
743+
744+
tmux_version = str(get_version(tmux_bin=server.tmux_bin))
745+
_fields, fmt_str = get_output_format("list-sessions", tmux_version)
746+
747+
old_lc_ctype = locale.setlocale(locale.LC_CTYPE)
748+
try:
749+
locale.setlocale(locale.LC_CTYPE, "C")
750+
proc = server.cmd("list-sessions", f"-F{fmt_str}")
751+
finally:
752+
locale.setlocale(locale.LC_CTYPE, old_lc_ctype)
753+
assert proc.stdout
754+
755+
line = proc.stdout[0]
756+
757+
assert FORMAT_SEPARATOR in line, (
758+
f"FORMAT_SEPARATOR U+241E not found in output; "
759+
f"got {line[:80]!r}... (likely decoded with wrong encoding)"
760+
)
761+
762+
result = parse_output(line, "list-sessions", tmux_version)
763+
assert isinstance(result, dict)
764+
assert "session_id" in result

tests/test_control_mode.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@
22

33
from __future__ import annotations
44

5+
import locale
6+
import os
7+
import select
8+
import sys
59
import typing as t
610

11+
import pytest
12+
713
from libtmux._internal.control_mode import ControlMode
14+
from libtmux.formats import FORMAT_SEPARATOR
815

916
if t.TYPE_CHECKING:
1017
from libtmux.server import Server
@@ -60,3 +67,33 @@ def test_control_mode_client_name_matches_spawned_client(
6067
assert first.client_name != second.client_name
6168
assert (str(first._proc.pid), first.client_name) in clients
6269
assert (str(second._proc.pid), second.client_name) in clients
70+
71+
72+
@pytest.mark.skipif(
73+
sys.flags.utf8_mode != 0,
74+
reason="PYTHONUTF8 mode forces UTF-8, masking the locale bug",
75+
)
76+
def test_control_mode_stdout_preserves_non_ascii_output(
77+
control_mode: t.Callable[[], ControlMode],
78+
) -> None:
79+
"""Control-mode stdout must preserve non-ASCII tmux output."""
80+
old_lc_ctype = locale.setlocale(locale.LC_CTYPE)
81+
try:
82+
locale.setlocale(locale.LC_CTYPE, "C")
83+
with control_mode() as ctl:
84+
os.write(
85+
ctl._write_fd,
86+
f"display-message -p '{FORMAT_SEPARATOR}'\n".encode(),
87+
)
88+
89+
for _ in range(20):
90+
ready, _, _ = select.select([ctl.stdout], [], [], 1)
91+
assert ready, "timed out waiting for control-mode output"
92+
93+
line = ctl.stdout.readline()
94+
if FORMAT_SEPARATOR in line:
95+
break
96+
else:
97+
pytest.fail("FORMAT_SEPARATOR U+241E not found in control output")
98+
finally:
99+
locale.setlocale(locale.LC_CTYPE, old_lc_ctype)

0 commit comments

Comments
 (0)