mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
10 files changed
+106
-28
No files matched your search
@@ -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)', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}');
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user