Skip to content

retire _transportable's refusal of -X=value, -t... and -- once a psmux release carries the psmux/psmux#795 fix #914

Open

Description

@dracic

psmux fixed psmux/psmux#795 on master in 20ef00fb. Until then, its client argv normalizers rewrote option values shaped like flags: -x=foo was stored as -x foo, and -t… and a bare -- were refused. bmad-loop works around that in #908 (#905): _transportable refuses those three shapes, so they take the visible path (a warning, and the key freed) instead of being stored corrupted. As of 2026-10-14 no release carries the fix. The latest tag is v3.4.0, and git tag --contains 20ef00fb is empty.

What retires: the clause in PsmuxMultiplexer._transportable (src/bmad_loop/adapters/psmux_backend.py):

if value == "--" or value.startswith("-t") or re.match(r"-[A-Za-z]=.", value):
    return False

Also retire the sentence in docs/multiplexer-backends.md ("Refused option values") that names these three shapes.

What stays: every other _transportable rule (empty, a double quote, a line break, edge whitespace). Each one has its own reason in the docstring, and none of them depends on psmux/psmux#795.

The trigger

tests/test_psmux_live.py::test_premise_an_argv_flag_shaped_option_value_is_not_stored pins the 3.4.0 behaviour for each shape: -x=foo reads back -x foo at rc 0, and -t… and -- fail. It turns red on a psmux build that stores them verbatim. The weekly psmux-canary workflow runs the live gate against the latest psmux release, so that is where the red shows first.

How to retire it (not a plain deletion)

Retire it behind the version gate, as #900 did for psmux/psmux#583:

  1. Move _LAST_UNSUPPORTED to the last release without 20ef00fb (the gate is exclusive), and move the CI gate's pin (ci.yml, version:) to the first release that has it.
  2. Delete the clause and its doc sentence.
  3. Turn the premise row into an ordinary-direction test_adopted_* row: the three shapes are stored verbatim. Show that it fails against a fake of the 3.4.0 behaviour.
  4. Add a CHANGELOG entry: an upgrade item for the floor, plus Removed for the refusal.

On any admitted build below the fix, removing the clause brings back silent corruption of a -X=value option value. A tag that reads back differently makes a window silently unprunable.

Checked: bmad-loop is not exposed to the rest of psmux/psmux#795

While fixing it, the maintainer found the same corruption in rename-session, set-buffer, new-window -n and display -p. bmad-loop never passes those commands a value starting with -:

  • session names are bmad-loop-<run id> and bmad-loop-ctl;
  • window names are run, sweep and control-window names that start with a letter;
  • it does not use set-buffer or rename-session;
  • its display-message -p calls pass only format strings that start with #{.

So _transportable remains the only workaround to retire.

Refs #905, #908

Activity

  1. dracic commented on Oct 14, 2026

    @dracic
    ContributorAuthor

    Retirement evidence from psmux master f720eddc: the clause is unnecessary there for every tested value, and still needed on 3.4.0

    Measured on psmux master f720eddc (built from source, unreleased; as checked on 2026-10-14 the latest release is still v3.4.0, released 2026-10-13, and no tag contains 20ef00fb), with bmad-loop at 45bac91c, Windows 11 build 26200, launched from PowerShell 7.6.6 with SHELL removed and PSMUX_NO_WARM=1. This adds the bmad-loop-side half of the retirement evidence. With only the dash-shaped refusal condition removed in a probe-local subclass, master stores every value in the table byte-exact through bmad-loop's session and window writers, and all four readers return it unchanged (3/3 independent runs per binary, plus further runs of the repro below). These are representative values for the three refused families, not an exhaustive proof over every matching string.

    The trigger fires on master, as designed. In a full-file run of tests/test_psmux_live.py on the master binary, and in three -k premise runs, all three rows of test_premise_an_argv_flag_shaped_option_value_is_not_stored fail the same way every run, reading back (0, '-x=foo'), (0, '-tx') and (0, '--'); the only other failure in the full-file run was the expected test_premise_window_scoped_option_write_lands_at_session_scope flip. On 3.4.0 the premise tests pass.

    Through bmad-loop's code, clause kept and clause removed. A probe-local subclass of PsmuxMultiplexer binds _BINARY to the binary's full path. With the clause kept (production) it is the positive control; then _transportable is replaced by the same function minus its --leading line. For each value it seeds "seed" on both channels, writes the value through set_session_option(S, "@tag", v) and set_window_option(S:@N, "@wtag", v), then reads it back through session_options("@tag"), show_window_option, _scoped_options and the @wtag column of list_windows:

    value master f720eddc, clause removed (all four readers) 3.4.0, clause removed both binaries, clause kept
    plain exact exact exact
    -x=foo -x=foo exact -x foo (rc 0, silently corrupted) refused with a warning, reads ''
    -X=a b exact -X a b refused
    -tfoo, -t=foo, -- exact write fails rc 1 (set-option: empty value for '@tag'); set_session_option raises TmuxError, the window channel warns; seed stays refused, reads ''
    -t exact rc 1, set-option: target required after -t; raises / warns; seed stays refused, reads ''
    -x=, -u, --x=y, -1=x, -w, -g exact exact (already admitted) exact

    For the tested values, this clause is unnecessary on master; on 3.4.0 it still prevents the corruption or write failures in the first six value rows. Retirement remains gated on raising the supported release floor. One detail the table adds to the issue body: on 3.4.0 an unrefused -t… or -- value makes set_session_option raise, not merely leave the seed in place, because BaseTmuxBackend.set_session_option uses the strict _tmux path. That is another reason the clause cannot go before the floor moves past 3.4.0.

    Source-derived: at bmad-loop 45bac91, src/bmad_loop/adapters/psmux_backend.py:975 is the dash-shaped refusal; :1009 delegates accepted session values to BaseTmuxBackend.set_session_option, whose src/bmad_loop/adapters/tmux_base.py:386-389 uses strict _tmux. Base _run raises on a non-zero rc at tmux_base.py:157-158. Window writes instead use _write_scoped at psmux_backend.py:1011-1026, with check=False and a warning on a non-zero rc. In psmux f720eddc, src/cli.rs:718-722 stops normalization at the first operand, and :1945-1954 exempts a lone trailing -- or -t after the option name from the extension parser. Commit 20ef00fb introduces these changes.

    The October hold on send-keys -l stays irrelevant. Source-derived at 45bac91: the in-tree generic adapter's four nudge call sites (generic.py:1610, :1780, :1990, :3366) send NUDGE_TEXT, STALL_NUDGE_TEXT, BUDGET_NUDGE_TEXT or a formatted CONTRACT_NUDGE_TEXT, each with a fixed You-prefixed start (generic.py:222-262). No examined in-tree nudge caller supplies a dash-leading operand to send-keys -l. opencode_http.py uses the same constants, but its send_text goes over HTTP, not psmux.

    Repro (run from PowerShell 7 with SHELL removed, in a bmad-loop checkout at 45bac91c after uv sync --all-extras; -Bin is the psmux to test, by full path; it uses a throwaway registry under %TEMP% and removes it):

    repro-914.ps1
    # Run from PowerShell 7 (pwsh) in a bmad-loop checkout at 45bac91c after `uv sync --all-extras`.
    #   pwsh -NoProfile -File repro-914.ps1 -Bin C:\path\to\psmux.exe   # the master build, then the installed 3.4.0 as the control
    param([Parameter(Mandatory)][string]$Bin)
    $ErrorActionPreference = 'Stop'
    Remove-Item Env:SHELL -ErrorAction SilentlyContinue
    $env:PSMUX_NO_WARM = '1'; $env:PYTHONUTF8 = '1'
    $Data = Join-Path $env:TEMP "psmux-914-probe-$PID"; New-Item -ItemType Directory -Force $Data | Out-Null
    $Py = Join-Path $env:TEMP "probe914-$PID.py"
    @'
    import contextlib, io, sys
    from pathlib import Path
    from bmad_loop.adapters.psmux_backend import PsmuxMultiplexer
    binary, data = sys.argv[1], sys.argv[2]
    class M(PsmuxMultiplexer):
        _BINARY = binary
    def relaxed(v):  # _transportable minus its "-"-leading line
        return bool(v) and '"' not in v and v == v.strip() and v.splitlines() == [v]
    m = M(registry_root=data)
    print(" / ".join(m._run(["-V"]).stdout.split()))
    S = "probe-914"
    m.new_session(S, Path(data))
    try:
        w = m.new_window(S, "w1", Path(data), {}, "pwsh -NoProfile -Command 'Start-Sleep 300'")
        key = m._scoped_option_key("@wtag", w.rsplit("@", 1)[1])
        def reads():  # all four readers
            opts = m._scoped_options(S) or {}
            return [m.session_options("@tag").get(S), m.show_window_option(w, "@wtag"),
                    opts.get(key), dict(m.list_windows(S, ["window_id", "@wtag"])).get(w)]
        for phase, values in [("clause kept", ["plain", "-x=foo", "-tfoo", "--", "-t"]),
                              ("clause removed", ["plain", "-x=foo", "-X=a b", "-tfoo", "-t=foo", "--", "-t",
                                                  "-x=", "-u", "--x=y", "-1=x", "-w", "-g"])]:
            if phase == "clause removed":
                M._transportable = staticmethod(relaxed)
            print(f"== {phase}")
            for v in values:
                m.set_session_option(S, "@tag", "seed"); m.set_window_option(w, "@wtag", "seed")
                err, raised = io.StringIO(), ""
                with contextlib.redirect_stderr(err):
                    try:
                        m.set_session_option(S, "@tag", v)
                    except Exception as e:
                        raised = f" raised {type(e).__name__}: {e}"
                    m.set_window_option(w, "@wtag", v)
                warned = " window warned" if err.getvalue() else ""
                print(f"{v!r:10} -> {reads()}{raised}{warned}")
    finally:
        m.kill_session(S)
    '@ | Set-Content $Py
    try {
        uv run python $Py $Bin $Data
    } finally {
        $env:PSMUX_DATA_DIR = $Data
        if ($env:PSMUX_DATA_DIR -eq $Data) { & $Bin kill-server 2>$null | Out-Null }
        Remove-Item Env:PSMUX_DATA_DIR
        Start-Sleep 1
        Remove-Item -Recurse -Force $Data, $Py -ErrorAction SilentlyContinue
    }

    Output on master (the clause-kept block is the same on 3.4.0):

    tmux / 3.4.0 / psmux / 3.4.0 / (unknown / commit)
    == clause kept
    'plain'    -> ['plain', 'plain', 'plain', 'plain']
    '-x=foo'   -> ['', '', None, ''] window warned
    '-tfoo'    -> ['', '', None, ''] window warned
    '--'       -> ['', '', None, ''] window warned
    '-t'       -> ['', '', None, ''] window warned
    == clause removed
    'plain'    -> ['plain', 'plain', 'plain', 'plain']
    '-x=foo'   -> ['-x=foo', '-x=foo', '-x=foo', '-x=foo']
    '-X=a b'   -> ['-X=a b', '-X=a b', '-X=a b', '-X=a b']
    '-tfoo'    -> ['-tfoo', '-tfoo', '-tfoo', '-tfoo']
    '-t=foo'   -> ['-t=foo', '-t=foo', '-t=foo', '-t=foo']
    '--'       -> ['--', '--', '--', '--']
    '-t'       -> ['-t', '-t', '-t', '-t']
    '-x='      -> ['-x=', '-x=', '-x=', '-x=']
    '-u'       -> ['-u', '-u', '-u', '-u']
    '--x=y'    -> ['--x=y', '--x=y', '--x=y', '--x=y']
    '-1=x'     -> ['-1=x', '-1=x', '-1=x', '-1=x']
    '-w'       -> ['-w', '-w', '-w', '-w']
    '-g'       -> ['-g', '-g', '-g', '-g']
    

    Output on 3.4.0, clause removed (paths shortened):

    'plain'    -> ['plain', 'plain', 'plain', 'plain']
    '-x=foo'   -> ['-x foo', '-x foo', '-x foo', '-x foo']
    '-X=a b'   -> ['-X a b', '-X a b', '-X a b', '-X a b']
    '-tfoo'    -> ['seed', 'seed', 'seed', 'seed'] raised TmuxError: ...\psmux.exe set-option -t failed: psmux: set-option: empty value for '@tag' window warned
    '-t=foo'   -> ['seed', 'seed', 'seed', 'seed'] raised TmuxError: ...\psmux.exe set-option -t failed: psmux: set-option: empty value for '@tag' window warned
    '--'       -> ['seed', 'seed', 'seed', 'seed'] raised TmuxError: ...\psmux.exe set-option -t failed: psmux: set-option: empty value for '@tag' window warned
    '-t'       -> ['seed', 'seed', 'seed', 'seed'] raised TmuxError: ...\psmux.exe set-option -t failed: psmux: set-option: target required after -t window warned
    '-x='      -> ['-x=', '-x=', '-x=', '-x=']
    '-u'       -> ['-u', '-u', '-u', '-u']
    '--x=y'    -> ['--x=y', '--x=y', '--x=y', '--x=y']
    '-1=x'     -> ['-1=x', '-1=x', '-1=x', '-1=x']
    '-w'       -> ['-w', '-w', '-w', '-w']
    '-g'       -> ['-g', '-g', '-g', '-g']
    

    Nothing here changes the plan in the body; it is still gated on a tagged psmux release that contains 20ef00fb.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions