fix(tmux): bind nested prefix before unbind so an empty table cannot abort the chain

A user tmux config that leaves the prefix table empty makes
unbind-key -a -T prefix fail and drop later commands in the \; chain,
including send-prefix and remain-on-exit failed. Bind C-q send-prefix,
clear the table, then bind it again.

Also accept macOS ttys in the TUI switch-guard test without reading
/proc, and describe the client_pid hook escape as defensive on tmux 3.4.

Co-authored-by: DDT <1786035110@qq.com>
This commit is contained in:
DDTandDDT committed 2026-10-23 09:08:48 +00:00
1 parent daeea54495
commit 9fc2773176
10 files changed
+106 -28

No files matched your search

+7 -3
View File
@@ -108,10 +108,14 @@ describe('buildDockerLaunchCommand', () => {
expect(cmd).toContain("sh -lc '");
// The in-container tmux keeps prefix C-q. Without this reduction its stock
// prefix table runs switch-client / choose-tree for the inner client.
// Bind, unbind-all, bind again: an empty prefix table makes unbind-key -a
// fail and abort the rest of the \; chain.
const firstBind = cmd.indexOf('bind-key -T prefix C-q send-prefix');
const unbind = cmd.indexOf('unbind-key -a -T prefix');
const bind = cmd.indexOf('bind-key -T prefix C-q send-prefix');
expect(unbind).toBeGreaterThan(cmd.indexOf('prefix C-q'));
expect(bind).toBeGreaterThan(unbind);
const secondBind = cmd.indexOf('bind-key -T prefix C-q send-prefix', unbind);
expect(firstBind).toBeGreaterThan(cmd.indexOf('prefix C-q'));
expect(unbind).toBeGreaterThan(firstBind);
expect(secondBind).toBeGreaterThan(unbind);
});
it('pins a deterministic conversation id with a reboot-surviving fallback (fresh launch)', () => {
+5 -2
View File
@@ -166,8 +166,11 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
`set -t ${remoteName} window-size latest`,
// #671 — the nested server's prefix table is stock unless this chain
// reduces it. Ctrl+Q then ) would switch-client onto a sibling session.
// Before remain-on-exit: a remote tmux that rejects `failed` aborts the
// rest of the chain, and that option has to stay last.
// Bind first: a user config that emptied the prefix table makes
// `unbind-key -a` fail and tmux drops the rest of the `\;` chain,
// including the bind and remain-on-exit. That option stays last because
// a remote tmux that rejects `failed` aborts the rest of the chain.
'bind-key -T prefix C-q send-prefix',
'unbind-key -a -T prefix',
'bind-key -T prefix C-q send-prefix',
// #210 — keep a CRASHED pane for diagnosis. `failed` (not `on`, which would
+37 -2
View File
@@ -13,7 +13,7 @@
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execSync } from 'node:child_process';
import { rmSync } from 'node:fs';
import { rmSync, writeFileSync } from 'node:fs';
import * as pty from 'node-pty';
import {
nestedPrefixTableResetCommands,
@@ -285,7 +285,7 @@ describe.skipIf(!hasTmux)('restrictTmuxPrefixTable against a real tmux (private
describe.skipIf(!hasTmux)('nested Docker/remote tmux prefix table (private socket)', () => {
// What buildRemoteLaunchCommand / buildDockerLaunchCommand create: prefix C-q
// on a dedicated server, then the two commands nestedPrefixTableResetCommands
// on a dedicated server, then the commands nestedPrefixTableResetCommands
// emits. The inner client is the docker-exec / ssh process; Ctrl+Q is its prefix.
const socket = `codeman-nested-prefix-${process.pid}`;
const tmux = `tmux -L ${socket} -f /dev/null`;
@@ -362,4 +362,39 @@ describe.skipIf(!hasTmux)('nested Docker/remote tmux prefix table (private socke
client!.write('\x11\x11');
expect(await waitFor(() => run(`${tmux} capture-pane -p -t alpha`).includes('^Q'))).toBe(true);
});
it('keeps the reset and the next command when the config left the prefix table empty', () => {
// Nested servers load the user's tmux config (no -f /dev/null). A config
// that empties the prefix table makes `unbind-key -a -T prefix` fail with
// "table prefix doesn't exist", and tmux drops every later command in the
// \; chain (the bind, and remain-on-exit failed). Binding send-prefix
// first is what lets both still run. One invocation, as the create chain is.
const socket = `codeman-nested-empty-${process.pid}`;
const config = `/tmp/${socket}.conf`;
writeFileSync(config, 'unbind-key -a -T prefix\n');
const base = `tmux -L ${socket}`;
const session = 'alpha';
const chain = [
`new-session -d -s ${session} -x 80 -y 24 'sleep 30'`,
...nestedPrefixTableResetCommands(),
`set -t ${session} remain-on-exit failed`,
].join(' \\; ');
try {
run(`${base} -f ${config} ${chain}`);
const prefix = run(`${base} list-keys -T prefix`)
.split('\n')
.filter((line) => line.trim());
expect(prefix).toEqual([expect.stringMatching(/-T prefix C-q\s+send-prefix$/)]);
expect(run(`${base} show-options -wv -t ${session} remain-on-exit`).trim()).toBe('failed');
} finally {
try {
const socketPath = run(`${base} display-message -p '#{socket_path}'`).trim();
run(`${base} kill-server`);
if (socketPath.endsWith(`/${socket}`)) rmSync(socketPath, { force: true });
} catch {
/* already gone */
}
rmSync(config, { force: true });
}
});
});
+3 -2
View File
@@ -682,8 +682,9 @@ describe('TUI switch-key guard', () => {
expect(isTuiClientTty('/dev/pts/4; rm')).toBe(false);
expect(tuiSwitchGuard('/dev/pts/4')).toContain('#{client_tty},/dev/pts/4');
expect(tuiSwitchGuard('/dev/pts/4')).toContain('#{@codeman-tui-pid}');
// A bare #{client_pid} is expanded when the hook is stored, with no client
// attached, and the pid is then empty forever. The backslash defers it.
// Defensive. On tmux 3.4 a bare #{client_pid} is also stored verbatim and
// records the pid. The backslash stays so a tmux that expands formats while
// storing the hook cannot save an empty pid.
const hook = tuiSwitchAttachHook('/dev/pts/4');
expect(hook).toContain('/dev/pts/4');
expect(hook).toContain('\\#{client_pid}');
+21 -5
View File
@@ -11,7 +11,7 @@
*/
import { afterAll, describe, expect, it } from 'vitest';
import { execFileSync, execSync, spawn, type ChildProcess } from 'node:child_process';
import { readlinkSync, rmSync } from 'node:fs';
import { rmSync } from 'node:fs';
import { createInterface } from 'node:readline';
import * as pty from 'node-pty';
import { TuiClient } from '../../src/tui/tui-client.js';
@@ -70,6 +70,9 @@ except ChildProcessError:
os.close(master)
`;
/** Linux `/dev/pts/N` and macOS `/dev/ttysNNN`, the same ttys `isTuiClientTty` accepts. */
const CLIENT_TTY = /^\/dev\/(?:pts\/[0-9]+|ttys[0-9]+)$/;
describe.skipIf(!hasTmux)('TUI M-1..M-9 switch only the attaching client', () => {
const socket = `codeman-tui-switch-${process.pid}`;
const alpha = 'codeman-aaaa1111';
@@ -146,13 +149,14 @@ describe.skipIf(!hasTmux)('TUI M-1..M-9 switch only the attaching client', () =>
helper = spawn('python3', ['-c', PTY_HELPER, socket, alpha], { stdio: ['pipe', 'pipe', 'inherit'] });
createInterface({ input: helper.stdout! }).on('line', pushLine);
const tty = (await nextLine()).trim();
expect(tty).toMatch(/^\/dev\/pts\/[0-9]+$/);
expect(tty).toMatch(CLIENT_TTY);
expect(await client.armTuiSwitchGuard(tty)).toBe(true);
const hooks = run(['show-hooks', '-g']);
expect(hooks).toContain('client-attached[90]');
expect(hooks).toContain(tty);
// Expanded at set-hook time this is empty and the guard never matches.
// The stored hook still names the format. Expanding it while the hook is
// stored would save an empty pid (no client yet) and the guard would never match.
expect(hooks).toContain('#{client_pid}');
helper.stdin!.write('GO\n');
@@ -185,9 +189,21 @@ describe.skipIf(!hasTmux)('TUI M-1..M-9 switch only the attaching client', () =>
rows: 24,
env,
});
const otherTty = readlinkSync(`/proc/${other.pid}/fd/0`);
// list-clients is the portable tty: /proc/<pid>/fd/0 does not exist on macOS.
let otherTty = '';
expect(
await waitFor(() => {
for (const [candidate, info] of sessions()) {
if (candidate !== tty && info.session === alpha) {
otherTty = candidate;
return true;
}
}
return false;
})
).toBe(true);
expect(otherTty).toMatch(CLIENT_TTY);
expect(otherTty).not.toBe(tty);
expect(await waitFor(() => sessions().get(otherTty)?.session === alpha)).toBe(true);
other.write('\x1b2');
await new Promise((resolve) => setTimeout(resolve, 400));