fix(tmux): isolate sessions on a dedicated socket + raise pane nofile limit (fixes new-session crash after tmux upgrade) (#96)

* fix: isolate codeman tmux sessions

* fix(tmux): unify all sessions onto a single dedicated socket

Remove the per-session `tmuxSocket` field that recorded which tmux server
each session lived on (default vs the `codeman` socket). That field was a
persisted cache of physical reality and could drift — causing live sessions
to be wrongly marked dead ("tab shows no session found") and spawning
duplicate "Restored:" tabs.

All Codeman sessions now live on one process-wide socket (`tmux -L codeman`,
overridable via CODEMAN_TMUX_SOCKET), exposed via TmuxManager.muxSocket on
the TerminalMultiplexer interface. reconcileSessions() collapses from a
multi-socket scan (locate / re-pin / cross-socket dedup) to a single
`list-panes` query. loadSessions() strips the obsolete field from on-disk
records so it stops being written back.

Also fix two sibling bare-`tmux` call sites the unification would otherwise
leave broken (same #80 regression class — bare tmux hits the user's default
server and never finds a session on the codeman socket):
- session.ts queryTmuxWindowSize(): add `-L <socket>` (was silently falling
  back to 120x40 on re-attach, losing scrollback)
- session-routes.ts send-key (Shift+Enter / Ctrl+Enter newline): route
  through ctx.mux.muxSocket

SSH chooser scripts (tmux-manager.sh, tmux-chooser.sh) route every tmux call
through `tmux -L $CODEMAN_TMUX_SOCKET`, matching the TS default.

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
This commit is contained in:
Tenggan Zhang
2026-05-25 23:28:58 +02:00
committed by GitHub
co-authored by Teigen
parent 08de6667ab
commit 1ff315a1e6
10 changed files with 287 additions and 93 deletions
+46
View File
@@ -7,6 +7,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return { ...actual, execFile };
});
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
describe('session-routes', () => {
@@ -20,6 +28,44 @@ describe('session-routes', () => {
await harness.app.close();
});
// ========== POST /api/sessions/:id/send-key ==========
describe('POST /api/sessions/:id/send-key', () => {
it('routes tmux send-keys through the dedicated Codeman socket (-L)', async () => {
// Regression guard: bare `tmux` would hit the user's default server and never
// find a session that lives only on the Codeman socket (#80 regression class).
execFile.mockReset();
execFile.mockImplementation((_bin: string, _argv: string[], _opts: unknown, cb: (e: Error | null) => void) =>
cb(null)
);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'S-Enter' },
});
expect(res.statusCode).toBe(200);
expect(execFile).toHaveBeenCalledTimes(1);
const [bin, argv] = execFile.mock.calls[0];
expect(bin).toBe('tmux');
expect((argv as string[]).slice(0, 2)).toEqual(['-L', 'codeman']);
expect(argv).toContain('send-keys');
expect(argv).toContain('-H');
});
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
execFile.mockReset();
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'rm -rf' },
});
expect(JSON.parse(res.body).success).toBe(false);
expect(execFile).not.toHaveBeenCalled();
});
});
// ========== GET /api/sessions ==========
describe('GET /api/sessions', () => {