Skip to content

Regroup listing records on the field separator, not newlines - #752

Open
tony wants to merge 1 commit into
masterfrom
fix-newline-in-format-values
Open

Regroup listing records on the field separator, not newlines#752
tony wants to merge 1 commit into
masterfrom
fix-newline-in-format-values

Conversation

@tony

@tony tony commented Aug 25, 2026

Copy link
Copy Markdown
Member

A pane whose pane_current_path contains a newline — a directory whose name has one — makes Server.panes and Server.windows raise ValueError: zip() argument 2 is shorter than argument 1 for the entire server, healthy panes included.

Reproduce

$ mkdir -p "/tmp/evil"$'\n'"dir"
$ tmux -L probe new-session -d -s base
$ tmux -L probe new-window -d -t base -c "/tmp/evil"$'\n'"dir"
>>> server = libtmux.Server(socket_name="probe")
>>> server.sessions   # may work, see below
>>> server.windows
ValueError: zip() argument 2 is shorter than argument 1
>>> server.panes
ValueError: zip() argument 2 is shorter than argument 1

Cause

fetch_objs iterated stdout one line per object:

outputs = [parse_output(line, list_cmd, tmux_version) for line in proc.stdout]

tmux writes one record per line, but a value containing a newline splits that record across two output lines. Each fragment then reaches parse_output with fewer values than the template has fields, and its zip(..., strict=True) raises.

Two properties make this worse than a single bad pane:

  • Every pane row carries pane_current_path, and every pane-targeting lookup enumerates panes, so one directory breaks resolution for all of them.
  • The blast radius moves with the active pane. Session and window rows resolve pane_* against the session's active pane, so the same server appears to work or fail depending on which pane the user last selected — which is why sessions above may or may not raise.

Fix

Records are regrouped on the field separator instead of on newlines. This is exact rather than merely better: the -F template from get_output_format terminates every field with a separator, so one record holds exactly len(fields) separators and a newline is never one of 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 that contains the separator itself previously corrupted the parse silently; a value count that is not a whole number of records now raises a LibTmuxException naming the cause.

Tests

_split_records is covered for a newline in the first, middle, and last field, consecutive newlines, a poisoned record between clean ones, empty values, zero records, and the forged-separator error.

Verified end to end against a real poisoned server: sessions, windows, and panes all enumerate, and the value round-trips exactly as '/tmp/evil\ndir'.

Provenance

Found while auditing libtmux-mcp, where an agent that cd-ed a pane into such a directory could not repair it through the MCP at all — every tool that could have moved the pane back needed the same enumeration.

tests/test_server.py::test_new_session_shell_env fails in my local environment before and after this change (new-session: command too long, caused by an unusually large environment being passed as -e flags); it is unrelated.

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.
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.53%. Comparing base (c056702) to head (5a75fe5).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
src/libtmux/neo.py 88.88% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #752      +/-   ##
==========================================
+ Coverage   52.37%   52.53%   +0.16%     
==========================================
  Files          26       26              
  Lines        3729     3746      +17     
  Branches      747      752       +5     
==========================================
+ Hits         1953     1968      +15     
- Misses       1472     1473       +1     
- Partials      304      305       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tony added a commit to tmux-python/libtmux-mcp that referenced this pull request Aug 25, 2026
A pane whose current directory contains a newline makes libtmux fail to
parse `-F` output, and because every pane lookup enumerates panes, the
whole server stops resolving — healthy panes included. It reached the
agent as `Unexpected error: ValueError: zip() argument 2 is shorter than
argument 1`, logged at ERROR, naming nothing it could act on. The agent
could not repair it through the MCP either: every tool that could have
moved the pane out needed the same enumeration.

It is now an expected failure that names the cause, says the blast
radius is server-wide rather than one pane, and gives the command that
locates the offender. Matched on the message because the raise site is
a stdlib `zip` with no dedicated exception type.

The parse itself is fixed upstream in tmux-python/libtmux#752, but this
diagnosis is kept rather than deferred: the floor is `libtmux>=0.62.0`
and the installed version is not this package's to choose.

`exc.PaneNotFound` prefixes its own message and the mapper prefixed it
again, so the most frequently hit error in the server read `Pane not
found: Pane not found: %9999`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant