Skip to content

Argument parser silently drops -x=VALUE form across all commands (has-session -t=NAME always exits 0) #196

@Avimarzan

Description

@Avimarzan

Summary

psmux's argument parser silently drops the -x=VALUE short-flag form (the tmux-compatible alternative to -x VALUE). The value is discarded and the command degrades to its targetless default. This affects has-session, capture-pane, set-option, new-session, kill-session, and every other command that parses flags via the args.windows(2).find(|w| w[0] == \"-t\") or cmd_args[i] == \"-t\" patterns.

The most visible symptom is has-session -t=NAME always returning exit 0 regardless of whether NAME exists, as long as any default server is running. This breaks tmux-compatible tools that use the = form as their exact-match escape hatch (for example claude-squad).

Concrete repro

Tested against psmux 3.3.1 (winget) and confirmed also present in master at 3.3.2 before this fix:

$ tmux ls
1: 1 windows (created ...)
develop: 1 windows (created ...)

$ tmux has-session -t=literally_any_garbage_xyzzy ; echo \$?
0          # BUG — should be 1, the session doesn't exist

$ tmux has-session -t literally_any_garbage_xyzzy ; echo \$?
1          # correct — space form works

$ tmux has-session -t=develop ; echo \$?
0          # correct, but only by accident

$ tmux has-session -t develop ; echo \$?
0          # correct

The -t=... form returns 0 for any string, even garbage, because the entire -t=literally_any_garbage_xyzzy token is treated as an unrecognized flag and skipped. The target never reaches PSMUX_TARGET_SESSION, the local has-session handler in src/main.rs:1146-1205 falls back to t = \"default\", and if any default psmux server is running the port file lookup succeeds and returns exit 0.

Root cause

Two layers of the parser compare tokens against a literal \"-t\":

Client-side (src/main.rs) — the global -t parser at line 110:

if let Some(pos) = args.iter().position(|a| a == \"-t\") {
    if let Some(target) = args.get(pos + 1) { ... }
}

and the local has-session handler at line 1154:

if cmd_args[i].as_str() == \"-t\" {
    if let Some(v) = cmd_args.get(i + 1) { t = v.to_string(); }
    i += 1;
}

Neither matches -t=NAME — the token doesn't equal \"-t\", it equals \"-t=NAME\". The -t=NAME token then starts with -, so the fallback non-flag branch (!cmd_args[i].starts_with('-')) skips it too. Result: `t` stays at the default \"default\".

Server-side (src/server/connection.rs) — every per-command handler uses:

let ... = args.windows(2).find(|w| w[0] == \"-t\").map(|w| w[1]...);

This pattern only matches the space form. -t=NAME, -S=VALUE, -E=VALUE, -F=VALUE, -s=VALUE, -c=VALUE, etc. are all silently dropped. There are 30+ such sites in connection.rs, plus one in src/app.rs:324 (new-window -n handling).

Impact

  • has-session with the = form is unusable as a predicate. Always returns 0 when any server is up.
  • Any command with an = flag gets the wrong target. kill-session -t=X, set-option -t=X, capture-pane -t=X ... etc. will operate on whatever default session the client ends up connected to, not the intended target.
  • Tools shelling out to tmux are blocked. The claude-squad TUI uses tmux has-session -t=NAME specifically to dodge real-tmux's prefix-match foot-gun. Against psmux this silently always-succeeds, causing `cs n` to hang on "session already exists" before ever reaching `new-session`.

Proposed fix

Single-pass normalization of `-x=VALUE` → `-x`, `VALUE` at argument entry. One helper function (`src/args.rs::normalize_flag_equals`), called in three places:

  1. `src/main.rs` `run_main()` — at the very top, on `env::args().collect()`. Covers the global `-t` parse, all per-subcommand handlers in main.rs, and the local has-session handler.
  2. `src/server/connection.rs` — at both dispatch sites (there are two), right after `parse_command_line()`. Defense-in-depth for commands that traverse the control socket.
  3. `src/app.rs` — the separate control dispatch inside the TUI event loop. Has its own `line.split_whitespace()` parse.

Rules:

  • Only tokens starting with a single `-` (not `--`) and containing `=` are split.
  • Long flags (`--name=VALUE`) are left untouched; callers parse them differently.
  • Positional args with `=` (e.g. `FOO=bar` to `send-keys`) are left untouched.
  • Bare `-` (stdin marker) and degenerate `-=` are left untouched.

Test methodology

The bug is invisible to positive-only testing. `has-session -t=real_session` returns 0 against either the buggy or the fixed binary, which looks correct. The test that exposes the bug is the garbage-input case — `has-session -t=literally_any_garbage_xyzzy` must return 1. I've included this as a regression test.

Related finding (separate issue)

While investigating I noticed `src/server/mod.rs:2056-2058`:

CtrlReq::HasSession(resp) => {
    let _ = resp.send(true);
}

The server-side `has-session` handler is a stub that unconditionally returns `true`. For the client-side has-session path in `main.rs:1146-1205` this is dead code (the client handles has-session locally via port-file lookup), but it's reachable from the server's control-mode dispatch in `connection.rs` at lines 894 and 2120. I'll file this as a separate issue — it's not the claude-squad blocker and doesn't belong in the same PR.

Environment

  • psmux 3.3.1 (winget `marlocarlo.psmux`) and master branch at the time of this report
  • Windows 11, Git Bash
  • Rust 1.94.1

PR

I have a fix ready with 15 unit tests covering both the regression case and non-regression guards. Will submit shortly and reference this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions