Skip to content

Commit ed42992

Browse files
committed
neo(fix[parse]): Regroup records on the separator, not newlines
A pane whose `pane_current_path` contained a newline made `Server.panes` and `Server.windows` raise `ValueError: zip() argument 2 is shorter than argument 1` for the entire server, healthy panes included. `fetch_objs` iterated stdout one line per object, so a value containing a newline split its record across two lines and each fragment reached `parse_output` with too few values. Every pane row carries `pane_current_path` and every pane-targeting lookup enumerates panes, so one directory took out resolution for all of them. The blast radius also moved with the active pane, because session and window rows resolve `pane_*` against it — the same server appeared to work or fail as the user switched panes. Regrouping on the field separator is exact rather than merely better: the `-F` template terminates every field with one, so a record holds exactly `len(fields)` separators and a newline is never among them. Nothing is split on newlines any more, so a value may contain any number of them, in any position. The newline that terminated the previous record survives the rejoin glued to the next record's first value and is stripped as the delimiter it is. Regrouping also makes a forged separator detectable: a value count that is not a whole number of records now raises a `LibTmuxException` naming the cause instead of surfacing a `zip()` message. Reported against libtmux-mcp, where an agent hit it by cd-ing a pane into such a directory and then could not repair it through the MCP, because every tool that could have moved the pane needed the same enumeration.
1 parent 036c521 commit ed42992

3 files changed

Lines changed: 158 additions & 1 deletion

File tree

CHANGES

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,33 @@ $ 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+
#### A newline in a format value no longer breaks every object listing
51+
52+
A pane whose `pane_current_path` contained a newline — a directory whose
53+
name has one — made `Server.panes` and `Server.windows` raise
54+
`ValueError: zip() argument 2 is shorter than argument 1` for the
55+
*entire* server, healthy panes included. `fetch_objs` iterated stdout
56+
one line per object, so a value containing a newline split its record
57+
across two lines and each fragment reached `parse_output` with too few
58+
values for its strict `zip`.
59+
60+
Because every pane row carries `pane_current_path`, and every
61+
pane-targeting lookup enumerates panes, one directory took out
62+
resolution for all of them. Which calls broke also depended on which
63+
pane happened to be active, since session and window rows resolve
64+
`pane_*` against the active pane — so the same server appeared to work
65+
or fail as the user switched panes.
66+
67+
Records are now regrouped on the field separator instead of on
68+
newlines. The `-F` template terminates every field with a separator, so
69+
a record holds exactly as many separators as it has fields and a
70+
newline is never one of them; a value may now contain any number of
71+
newlines in any position. A value that carries the separator itself no
72+
longer corrupts the parse silently — it is reported as output that
73+
could not be parsed.
74+
4875
### Documentation
4976

5077
#### Cleaner `from_env` examples (#719)

src/libtmux/neo.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,58 @@ def parse_output(
10361036
return {k: v for k, v in formatter.items() if v}
10371037

10381038

1039+
def _split_records(stdout: list[str], field_count: int) -> list[str]:
1040+
"""Regroup ``-F`` output into one string per object.
1041+
1042+
tmux writes one record per line, but any format value may itself
1043+
contain a newline -- ``pane_current_path`` for a directory whose
1044+
name has one -- and that splits the record across output lines.
1045+
Iterating lines then hands :func:`parse_output` a fragment with too
1046+
few values, which its strict ``zip`` rejects, so one directory
1047+
breaks every object on the server rather than the one pane in it.
1048+
1049+
Regrouping on the separator is exact rather than merely better: the
1050+
template from :func:`get_output_format` terminates *every* field
1051+
with a separator, so one record holds exactly ``field_count`` of
1052+
them and a newline is never one. Nothing is split on newlines, so a
1053+
value may contain any number of them, in any position.
1054+
1055+
Raises
1056+
------
1057+
:exc:`~libtmux.exc.LibTmuxException`
1058+
If the values do not divide into whole records, which means a
1059+
value contained the separator itself.
1060+
"""
1061+
blob = "\n".join(stdout)
1062+
if not blob:
1063+
return []
1064+
1065+
values = blob.split(FORMAT_SEPARATOR)
1066+
# Every record ends with a separator, so the split always leaves one
1067+
# trailing empty for the final record.
1068+
if values and values[-1] == "":
1069+
values.pop()
1070+
1071+
if field_count <= 0 or len(values) % field_count:
1072+
msg = (
1073+
f"tmux output could not be parsed: {len(values)} values for "
1074+
f"{field_count} fields per record. A format value probably "
1075+
f"contains the field separator ({FORMAT_SEPARATOR!r})."
1076+
)
1077+
raise exc.LibTmuxException(msg)
1078+
1079+
records: list[str] = []
1080+
for start in range(0, len(values), field_count):
1081+
chunk = values[start : start + field_count]
1082+
# The newline that terminated the previous record survives the
1083+
# join glued to this record's first value. It is a delimiter,
1084+
# not data.
1085+
if start and chunk[0].startswith("\n"):
1086+
chunk[0] = chunk[0][1:]
1087+
records.append(FORMAT_SEPARATOR.join(chunk) + FORMAT_SEPARATOR)
1088+
return records
1089+
1090+
10391091
def fetch_objs(
10401092
server: Server,
10411093
list_cmd: ListCmd,
@@ -1137,7 +1189,10 @@ def fetch_objs(
11371189

11381190
raise_if_stderr(proc, list_cmd)
11391191

1140-
outputs = [parse_output(line, list_cmd, tmux_version) for line in proc.stdout]
1192+
outputs = [
1193+
parse_output(record, list_cmd, tmux_version)
1194+
for record in _split_records(proc.stdout, len(_fields))
1195+
]
11411196

11421197
if logger.isEnabledFor(logging.DEBUG):
11431198
if cmd_str is None:

tests/test_neo.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313

1414
import pytest
1515

16+
from libtmux import exc
17+
from libtmux.formats import FORMAT_SEPARATOR
1618
from libtmux.neo import (
1719
_CONTEXT_ONLY_TOKENS,
1820
FIELD_VERSION,
1921
SCOPES_BY_LIST_CMD,
2022
Obj,
2123
_is_target_not_found_error,
24+
_split_records,
2225
_token_scope,
2326
get_output_format,
2427
)
@@ -258,3 +261,75 @@ def test_every_obj_field_classifies_to_known_scope() -> None:
258261
"(add them to _SCOPE_OVERRIDES, _SCOPE_PREFIXES, "
259262
f"_UNIVERSAL_TOKENS, or _CONTEXT_ONLY_TOKENS): {unclassified}"
260263
)
264+
265+
266+
class SplitRecordsFixture(t.NamedTuple):
267+
"""Test fixture for :func:`_split_records`."""
268+
269+
test_id: str
270+
values: list[list[str]]
271+
272+
273+
SPLIT_RECORDS_FIXTURES: list[SplitRecordsFixture] = [
274+
SplitRecordsFixture("single_clean_record", [["a", "b", "c"]]),
275+
SplitRecordsFixture("two_clean_records", [["a", "b", "c"], ["d", "e", "f"]]),
276+
SplitRecordsFixture("newline_in_first_field", [["a\nx", "b", "c"]]),
277+
SplitRecordsFixture("newline_in_middle_field", [["a", "b\nx", "c"]]),
278+
SplitRecordsFixture("newline_in_last_field", [["a", "b", "c\nx"]]),
279+
SplitRecordsFixture("consecutive_newlines", [["a", "b\n\n\nx", "c"]]),
280+
SplitRecordsFixture(
281+
"poisoned_record_between_clean_ones",
282+
[["a", "b", "c"], ["d", "e\npath", "f"], ["g", "h", "i"]],
283+
),
284+
SplitRecordsFixture("empty_values", [["", "", ""]]),
285+
]
286+
287+
288+
@pytest.mark.parametrize(
289+
SplitRecordsFixture._fields,
290+
SPLIT_RECORDS_FIXTURES,
291+
ids=[fixture.test_id for fixture in SPLIT_RECORDS_FIXTURES],
292+
)
293+
def test_split_records_round_trips_newlines(
294+
test_id: str,
295+
values: list[list[str]],
296+
) -> None:
297+
"""A newline inside a value must not split its record.
298+
299+
tmux emits one record per line, so a value containing a newline --
300+
``pane_current_path`` under a directory whose name has one -- used
301+
to arrive as two short fragments and fail ``parse_output``'s strict
302+
``zip``. Because every pane row carries ``pane_current_path``, that
303+
broke enumeration for the whole server, not just the one pane.
304+
"""
305+
assert test_id
306+
field_count = len(values[0])
307+
# Rebuild exactly what tmux writes: each record's fields, every one
308+
# terminated by the separator, and records terminated by newlines.
309+
stdout_text = "".join(
310+
"".join(f"{value}{FORMAT_SEPARATOR}" for value in record) + "\n"
311+
for record in values
312+
)
313+
stdout = stdout_text.split("\n")
314+
while stdout and stdout[-1] == "":
315+
stdout.pop()
316+
317+
records = _split_records(stdout, field_count)
318+
319+
assert len(records) == len(values)
320+
for record, expected in zip(records, values, strict=True):
321+
parsed = record.split(FORMAT_SEPARATOR)[:-1]
322+
assert parsed == expected
323+
324+
325+
def test_split_records_reports_a_forged_separator() -> None:
326+
"""A value carrying the separator is named, not a ``zip`` message."""
327+
stdout = [f"a{FORMAT_SEPARATOR}b{FORMAT_SEPARATOR}c{FORMAT_SEPARATOR}"]
328+
329+
with pytest.raises(exc.LibTmuxException, match="could not be parsed"):
330+
_split_records(stdout, 2)
331+
332+
333+
def test_split_records_handles_no_objects() -> None:
334+
"""An empty listing yields no records rather than a bogus one."""
335+
assert _split_records([], 5) == []

0 commit comments

Comments
 (0)