diff --git a/CHANGES b/CHANGES index f55d5312f..4c08d8f1f 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,17 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### Fixes + +#### Subprocess encoding on non-UTF-8 locales (#679) + +{class}`~libtmux.common.tmux_cmd` and {class}`~libtmux._internal.control_mode.ControlMode` +now pass `encoding="utf-8"` to `subprocess.Popen`, ensuring tmux output +is decoded correctly regardless of the system locale. Previously, on +non-UTF-8 locales, the {data}`~libtmux.formats.FORMAT_SEPARATOR` character +(U+241E) was corrupted during decoding, causing list accessors +({attr}`~libtmux.Server.sessions`, etc.) to return empty results. + ## libtmux 0.57.1 (2026-05-18) Restores the "lenient-by-default" behavior for diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index c4ef1d2da..6c1d2301e 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -86,6 +86,7 @@ def __enter__(self) -> ControlMode: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", ) finally: # subprocess owns read_fd now diff --git a/src/libtmux/common.py b/src/libtmux/common.py index d05a0b2ea..06c92320f 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -333,6 +333,7 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", errors="backslashreplace", ) stdout, stderr = self.process.communicate() diff --git a/tests/test_common.py b/tests/test_common.py index 59f926eaa..426b72d57 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -2,6 +2,7 @@ from __future__ import annotations +import locale import logging import re import sys @@ -713,3 +714,51 @@ def test_raise_if_stderr_str_shape_exact(session: libtmux.Session) -> None: assert str(excinfo.value) == "last-window: no last window" assert excinfo.value.args == ("no last window",) assert excinfo.value.subcommand == "last-window" + + +@pytest.mark.skipif( + sys.flags.utf8_mode != 0, + reason="PYTHONUTF8 mode forces UTF-8, masking the locale bug", +) +def test_tmux_cmd_format_separator_survives_non_utf8_locale( + session: Session, +) -> None: + """FORMAT_SEPARATOR must survive a non-UTF-8 locale round-trip through tmux_cmd. + + Regression test for the encoding bug introduced in commit 1a5e69a2 + (``tmux_cmd: Remove console_to_str(), use text=True``). When + ``subprocess.Popen`` receives ``text=True`` without an explicit + ``encoding="utf-8"``, CPython falls back to the process locale encoding. On + a ``C`` locale the FORMAT_SEPARATOR character U+241E (UTF-8 bytes + ``e2 90 9e``) is decoded as escaped bytes, corrupting every + ``parse_output()`` call downstream. + + This test guards the explicit ``encoding="utf-8"`` passed to + ``subprocess.Popen`` in ``tmux_cmd.__init__``. + """ + from libtmux.formats import FORMAT_SEPARATOR + from libtmux.neo import get_output_format, parse_output + + server = session.server + + tmux_version = str(get_version(tmux_bin=server.tmux_bin)) + _fields, fmt_str = get_output_format("list-sessions", tmux_version) + + old_lc_ctype = locale.setlocale(locale.LC_CTYPE) + try: + locale.setlocale(locale.LC_CTYPE, "C") + proc = server.cmd("list-sessions", f"-F{fmt_str}") + finally: + locale.setlocale(locale.LC_CTYPE, old_lc_ctype) + assert proc.stdout + + line = proc.stdout[0] + + assert FORMAT_SEPARATOR in line, ( + f"FORMAT_SEPARATOR U+241E not found in output; " + f"got {line[:80]!r}... (likely decoded with wrong encoding)" + ) + + result = parse_output(line, "list-sessions", tmux_version) + assert isinstance(result, dict) + assert "session_id" in result diff --git a/tests/test_control_mode.py b/tests/test_control_mode.py index 609357ed3..f72f68466 100644 --- a/tests/test_control_mode.py +++ b/tests/test_control_mode.py @@ -2,9 +2,16 @@ from __future__ import annotations +import locale +import os +import select +import sys import typing as t +import pytest + from libtmux._internal.control_mode import ControlMode +from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: from libtmux.server import Server @@ -60,3 +67,33 @@ def test_control_mode_client_name_matches_spawned_client( assert first.client_name != second.client_name assert (str(first._proc.pid), first.client_name) in clients assert (str(second._proc.pid), second.client_name) in clients + + +@pytest.mark.skipif( + sys.flags.utf8_mode != 0, + reason="PYTHONUTF8 mode forces UTF-8, masking the locale bug", +) +def test_control_mode_stdout_preserves_non_ascii_output( + control_mode: t.Callable[[], ControlMode], +) -> None: + """Control-mode stdout must preserve non-ASCII tmux output.""" + old_lc_ctype = locale.setlocale(locale.LC_CTYPE) + try: + locale.setlocale(locale.LC_CTYPE, "C") + with control_mode() as ctl: + os.write( + ctl._write_fd, + f"display-message -p '{FORMAT_SEPARATOR}'\n".encode(), + ) + + for _ in range(20): + ready, _, _ = select.select([ctl.stdout], [], [], 1) + assert ready, "timed out waiting for control-mode output" + + line = ctl.stdout.readline() + if FORMAT_SEPARATOR in line: + break + else: + pytest.fail("FORMAT_SEPARATOR U+241E not found in control output") + finally: + locale.setlocale(locale.LC_CTYPE, old_lc_ctype)