Compare commits

...
Author SHA1 Message Date
dev-guard aa7a062beb fix(tmux): raise open-file limit for new servers 2026-11-12 11:37:23 +00:00
11 changed files with 199 additions and 16 deletions

No files matched your search

+6
View File
@@ -61,6 +61,12 @@ Existing OpenRig session options, such as `terminal.status_bar`, still apply.
An existing server does not reread its configuration merely because the daemon
restarts. Settings and sessions in a separate personal server are left alone.
When OpenRig creates a tmux server on macOS or Linux, its creating client tries
to raise the soft open-file limit to 8192, bounded by the inherited hard limit.
An already higher soft limit is preserved. A failed raise produces an advisory
and the start continues with the inherited limit. Connecting to an existing
server does not change that server's limit or restart its sessions.
## Existing teams and deferred migration
At startup, the daemon checks the previously selected server before switching.
+7 -3
View File
@@ -4,7 +4,8 @@ import { readOpenRigEnv } from "../openrig-compat.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { execFileSync } from "node:child_process";
import { spawnSync } from "node:child_process";
import { reportTmuxServerLimit, tmuxServerStartCommand } from "@openrig/daemon/tmux-server-start";
/** tmux's arguments for the session that runs `rig chatroom watch`. The watcher goes to tmux as separate arguments, so
* tmux runs it without a shell and the rig name arrives as one argument; `--` keeps a name that starts with `-` from
@@ -193,8 +194,11 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
const sessionName = `chatroom@${rig}`;
try {
// The name tmux used, which differs from sessionName when tmux changed it (a `.` in a rig name, for example).
const created = execFileSync("tmux", chatroomWatchTmuxArgs(rig), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
.trim() || sessionName;
const command = tmuxServerStartCommand("tmux", chatroomWatchTmuxArgs(rig));
const result = spawnSync(command.file, command.args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
reportTmuxServerLimit(result.stderr);
if (result.error || result.status !== 0) throw result.error ?? new Error("tmux session creation failed");
const created = result.stdout.trim() || sessionName;
console.log(`Started watch in tmux session: ${created}`);
console.log(`Attach with: tmux attach -t ${created}`);
} catch {
+12 -1
View File
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { promisify } from "node:util";
import { reportTmuxServerLimit, tmuxServerStartCommand } from "@openrig/daemon/tmux-server-start";
import { terminalAuthHeaders, type DaemonClient } from "./client.js";
import { shellQuote } from "./cross-host-executor.js";
import { prepareHerdrLaunchConfig } from "./herdr-launch-config.js";
@@ -43,7 +44,17 @@ export function defaultWindowDeps(): WindowDeps {
const run = promisify(execFile);
return {
platform: process.platform, env, exists: existsSync,
exec: async (file, args, timeoutMs = 10_000) => (await run(file, args, { env, encoding: "utf8", timeout: timeoutMs })).stdout,
exec: async (file, args, timeoutMs = 10_000) => {
const command = file === "tmux" ? tmuxServerStartCommand(file, args) : { file, args };
try {
const result = await run(command.file, command.args, { env, encoding: "utf8", timeout: timeoutMs });
reportTmuxServerLimit(result.stderr);
return result.stdout;
} catch (error) {
reportTmuxServerLimit((error as { stderr?: unknown }).stderr);
throw error;
}
},
launch: (file, args) => new Promise((resolve, reject) => {
const child = spawn(file, args, { env, detached: true, stdio: "ignore" });
child.once("error", reject);
+3 -1
View File
@@ -3,6 +3,7 @@ import { readFileSync, realpathSync } from "node:fs";
import { hostname, networkInterfaces } from "node:os";
import path from "node:path";
import type { DaemonClient } from "./client.js";
import { tmuxServerStartCommand } from "@openrig/daemon/tmux-server-start";
export interface TmuxAttachment {
session: string;
@@ -86,7 +87,8 @@ export function attachTmux(target: TmuxAttachment): Promise<number> {
const env = { ...process.env };
delete env.TMUX;
delete env.TMUX_PANE;
const child = spawn("tmux", [...server, "attach-session", ...(target.readOnly ? ["-r"] : []), "-t", `=${target.session}`],
const command = tmuxServerStartCommand("tmux", [...server, "attach-session", ...(target.readOnly ? ["-r"] : []), "-t", `=${target.session}`]);
const child = spawn(command.file, command.args,
{ stdio: "inherit", env, windowsHide: true });
child.once("error", reject);
child.once("exit", code => resolve(code ?? 1));
+6 -2
View File
@@ -14,9 +14,13 @@ describe("tmux attachment", () => {
const child = new EventEmitter();
vi.mocked(spawn).mockReturnValue(child as ReturnType<typeof spawn>);
const result = attachTmux({ session: "-seat ' quoted", serverArgs: ["-S", "/private/instance socket"], readOnly });
expect(spawn).toHaveBeenCalledWith("tmux", [
const [file, args, options] = vi.mocked(spawn).mock.calls[0]!;
expect(file).toBe(process.platform === "win32" ? "tmux" : "/bin/sh");
expect(process.platform === "win32" ? args : args!.slice(2)).toEqual([
...(process.platform === "win32" ? [] : ["tmux"]),
"-S", "/private/instance socket", "attach-session", ...(readOnly ? ["-r"] : []), "-t", "=-seat ' quoted",
], expect.objectContaining({ stdio: "inherit" }));
]);
expect(options!.stdio).toBe("inherit");
const env = vi.mocked(spawn).mock.calls[0]![2]!.env!;
expect(env.TMUX).toBeUndefined();
expect(env.TMUX_PANE).toBeUndefined();
+1
View File
@@ -5,6 +5,7 @@
"license": "Apache-2.0",
"type": "module",
"exports": {
"./tmux-server-start": { "types": "./dist/tmux-server-start-surface.d.ts", "import": "./dist/tmux-server-start-surface.js" },
"./bundle-behaviour": { "types": "./dist/bundle-behaviour-surface.d.ts", "import": "./dist/bundle-behaviour-surface.js" },
"./router-line": { "types": "./dist/router-line-surface.d.ts", "import": "./dist/router-line-surface.js" },
"./spec-validation": {
+38 -8
View File
@@ -3,6 +3,7 @@ import { accessSync, constants, statSync } from "node:fs";
import { isAbsolute, join } from "node:path";
import { promisify } from "node:util";
import type { ArgvExecFn, ExecFn } from "./tmux.js";
import { mayStartTmuxServer, reportTmuxServerLimit, TMUX_SERVER_LIMIT_PREFIX, tmuxServerStartCommand } from "../tmux-server-start.js";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -93,19 +94,31 @@ function withExecOutput(err: unknown): unknown {
*/
export const execCommand: ExecFn = async (cmd: string): Promise<string> => {
try {
const { stdout } = await execAsync(cmd, { env: /^tmux(?:\s|$)/.test(cmd) ? tmuxClientEnvironment() : process.env });
const { stdout, stderr } = await execAsync(tmuxStartShell(cmd), { env: /^tmux(?:\s|$)/.test(cmd) ? tmuxClientEnvironment() : process.env });
reportTmuxServerLimit(stderr);
return stdout;
} catch (err) {
reportTmuxServerLimit((err as { stderr?: unknown }).stderr);
throw withExecOutput(err);
}
};
function tmuxStartShell(cmd: string, replaceShell = false): string {
const creating = process.platform !== "win32" && /^tmux\s/.test(cmd)
&& /(?:^|\s)(?:new-session|start-server|attach-session|-D)(?:\s|$)/.test(cmd.replaceAll("'", ""))
? TMUX_SERVER_LIMIT_PREFIX : "";
return creating + (replaceShell && /^tmux\s/.test(cmd) ? `exec ${cmd}` : cmd);
}
/** Replace the shell only for tmux clients; other adapter commands keep shell builtins such as kill. */
export const execTmuxCommand: ExecFn = async (cmd: string): Promise<string> => {
const started = performance.now();
try {
return (await execFileAsync("/bin/sh", ["-c", /^tmux\s/.test(cmd) ? `exec ${cmd}` : cmd], { ...tmuxExecOptions, env: tmuxClientEnvironment() })).stdout;
const result = await execFileAsync("/bin/sh", ["-c", tmuxStartShell(cmd, true)], { ...tmuxExecOptions, env: tmuxClientEnvironment() });
reportTmuxServerLimit(result.stderr);
return result.stdout;
} catch (err) {
reportTmuxServerLimit((err as { stderr?: unknown }).stderr);
// Tokens are diagnostic labels only; the command is executed by the shell without rewriting its arguments.
const labels = (cmd.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map(word => word.replace(/^(['"])(.*)\1$/, "$2"));
warnTmuxDeadline(err, started, labels);
@@ -115,10 +128,10 @@ export const execTmuxCommand: ExecFn = async (cmd: string): Promise<string> => {
/**
* Production ArgvExecFn for TmuxAdapter.
* Runs argv[0] directly via execFile: no shell, no quoting layer. On POSIX
* argv[0] is started by its resolved path: with a bare name, libuv tries each
* PATH entry with a spawn on the event loop. psmux ships a `tmux` alias on
* PATH, so Windows passes the bare name to the spawn.
* Ordinary commands run directly via execFile. Server-creating POSIX commands
* use a child shell to raise only their inherited soft file limit, then exec the
* unchanged argv. Other POSIX commands use a resolved path to avoid libuv's
* repeated PATH spawns on the event loop. Windows keeps the psmux alias on PATH.
*/
export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<string> => {
const [bin, ...args] = argv;
@@ -127,7 +140,14 @@ export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<strin
// Nothing ran when no file was found: a shell also finds tmux through an entry the lookup skips, such as a literal
// `~/bin`, and reports a missing one as the shell transport does.
const opts = { ...tmuxExecOptions, env: tmuxClientEnvironment() };
const run = (file: string | null) => file ? execFileAsync(file, args, opts) : execFileAsync("/bin/sh", ["-c", 'exec "$0" "$@"', bin, ...args], opts);
const run = async (file: string | null) => {
const command = tmuxServerStartCommand(mayStartTmuxServer(args) ? bin : file ?? bin, args);
const result = command.file !== "/bin/sh" && !file
? await execFileAsync("/bin/sh", ["-c", 'exec "$0" "$@"', bin, ...args], opts)
: await execFileAsync(command.file, command.args, opts);
reportTmuxServerLimit(result.stderr);
return result;
};
try {
if (process.platform === "win32") return (await execFileAsync(bin, args, opts)).stdout;
const file = resolvedOnPath(bin);
@@ -141,6 +161,7 @@ export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<strin
return (await run(null)).stdout;
}
} catch (err) {
reportTmuxServerLimit((err as { stderr?: unknown }).stderr);
warnTmuxDeadline(err, started, argv);
throw withExecOutput(err);
}
@@ -154,7 +175,16 @@ export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<strin
export async function spawnTmuxClient(args: string[]): Promise<ChildProcessWithoutNullStreams> {
const opts = { stdio: "pipe" as const, windowsHide: true, env: tmuxClientEnvironment() };
const started = (file: string | null) => new Promise<ChildProcessWithoutNullStreams>((resolve, reject) => {
const child = file ? spawn(file, args, opts) : spawn("/bin/sh", ["-c", 'exec "$0" "$@"', "tmux", ...args], opts);
const command = tmuxServerStartCommand(mayStartTmuxServer(args) ? "tmux" : file ?? "tmux", args);
const child = command.file !== "/bin/sh" && !file
? spawn("/bin/sh", ["-c", 'exec "$0" "$@"', "tmux", ...args], opts) : spawn(command.file, command.args, opts);
if (mayStartTmuxServer(args)) {
let notice = "";
child.stderr.on("data", data => {
notice = (notice + String(data)).slice(-512);
reportTmuxServerLimit(notice);
});
}
child.once("error", reject);
child.once("spawn", () => {
child.off("error", reject);
+2 -1
View File
@@ -9,6 +9,7 @@ import { parentSessionEnvKeys, removeParentSessionEnv } from "../domain/parent-s
import { addSeatRuntimeDir, seatRuntimeDir } from "../domain/seat-runtime-dir.js";
import type { TmuxControlExec } from "./tmux-control.js";
import { tmuxOnDaemonServer } from "./tmux-server.js";
import { TMUX_SERVER_LIMIT_PREFIX } from "../tmux-server-start.js";
export type ExecFn = (cmd: string) => Promise<string>;
@@ -730,7 +731,7 @@ export class TmuxAdapter {
this.scopeSkipLogged = true;
return fallback(unavailable);
}
const server = posixJoinArgv(["tmux", ...this.serverArgs, "-D"]);
const server = TMUX_SERVER_LIMIT_PREFIX + posixJoinArgv(["tmux", ...this.serverArgs, "-D"]);
// systemd-run 254+ expands ${VAR} in the command's arguments (by default from 258, also for --scope): the
// socket path must reach /bin/sh exactly as given, so expansion is turned off where the option exists.
const scoped = (oomPolicy: boolean, literal: boolean) => posixJoinArgv(["systemd-run", "--user", "--scope",
@@ -0,0 +1 @@
export * from "./tmux-server-start.js";
+37
View File
@@ -0,0 +1,37 @@
/** tmux 3.3a–3.6 inherits its creating client's limits. This runs in that child only:
* never lower an already higher soft limit, change the hard limit, or restart a server. */
export const TMUX_SERVER_LIMIT_WARNING = "[tmux] Could not raise the open-file limit for a new terminal server; continuing with the inherited limit.";
export const TMUX_SERVER_LIMIT_PREFIX = `
openrig_tmux_nofile() {
openrig_tmux_soft=$(ulimit -S -n) || return 1
case "$openrig_tmux_soft" in unlimited) return 0;; ''|*[!0-9]*) return 1;; esac
openrig_tmux_hard=$(ulimit -H -n) || return 1
openrig_tmux_target=8192
case "$openrig_tmux_hard" in
unlimited) ;;
''|*[!0-9]*) return 1;;
*) if [ "$openrig_tmux_hard" -lt "$openrig_tmux_target" ]; then openrig_tmux_target=$openrig_tmux_hard; fi;;
esac
[ "$openrig_tmux_soft" -ge "$openrig_tmux_target" ] || ulimit -S -n "$openrig_tmux_target"
}
openrig_tmux_nofile 2>/dev/null || printf '%s\\n' '${TMUX_SERVER_LIMIT_WARNING}' >&2
`;
/** Production callers use full command names. A matching data argument merely gets the same harmless child limit. */
export function mayStartTmuxServer(args: readonly string[]): boolean {
return args.some(arg => ["new-session", "start-server", "attach-session", "-D"].includes(arg));
}
export function tmuxServerStartCommand(file: string, args: string[]): { file: string; args: string[] } {
if (process.platform === "win32" || !mayStartTmuxServer(args)) return { file, args };
return { file: "/bin/sh", args: ["-c", TMUX_SERVER_LIMIT_PREFIX + 'exec "$0" "$@"', file, ...args] };
}
let warned = false;
/** execFile captures stderr even on success; surface this advisory once per process. */
export function reportTmuxServerLimit(stderr: unknown): void {
if (!warned && String(stderr ?? "").includes(TMUX_SERVER_LIMIT_WARNING)) {
warned = true;
console.warn(TMUX_SERVER_LIMIT_WARNING);
}
}
@@ -0,0 +1,86 @@
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { TMUX_SERVER_LIMIT_PREFIX, TMUX_SERVER_LIMIT_WARNING, tmuxServerStartCommand } from "../src/tmux-server-start.js";
const shell = (script: string, args: string[] = []) => spawnSync("/bin/sh", ["-c", script, "nofile-test", ...args], { encoding: "utf8", timeout: 10_000 });
let tmux = false;
try { execFileSync("tmux", ["-V"], { stdio: "ignore" }); tmux = true; } catch { /* Native prerequisite absent. */ }
describe.skipIf(process.platform === "win32")("tmux server creation limit", () => {
it("raises only the soft limit, capped by the hard limit and 8192", () => {
const result = shell('ulimit -S -n 256; ' + TMUX_SERVER_LIMIT_PREFIX + 'printf "%s %s" "$(ulimit -S -n)" "$(ulimit -H -n)"');
expect(result.status).toBe(0);
const [soft, hard] = result.stdout.split(" ");
expect(Number(soft)).toBe(hard === "unlimited" ? 8192 : Math.min(Number(hard), 8192));
expect(result.stderr).toBe("");
});
it("does not lower a soft limit above the target", ({ skip }) => {
const result = shell('ulimit -S -n 16384 2>/dev/null || exit 77; ' + TMUX_SERVER_LIMIT_PREFIX + 'ulimit -S -n');
if (result.status === 77) skip(); // This host's hard limit cannot supply the fixture.
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("16384");
});
it("honours a lower hard limit without changing it", ({ skip }) => {
const result = shell('ulimit -S -n 256; ulimit -H -n 1024 2>/dev/null || exit 77; ' + TMUX_SERVER_LIMIT_PREFIX + 'printf "%s %s" "$(ulimit -S -n)" "$(ulimit -H -n)"');
if (result.status === 77) skip();
expect(result.status).toBe(0);
expect(result.stdout).toBe("1024 1024");
expect(result.stderr).toBe("");
});
it("continues exactly once with unchanged argv and limit when raising fails", async () => {
const command = tmuxServerStartCommand("/bin/sh", ["-c", 'printf "ran:%s:%s" "$(ulimit -S -n)" "$1"', "payload", "quoted ' $HOME ; new-session", "new-session"]);
// Supply a real low inherited soft limit and a shell builtin override that refuses only a raise.
const prelude = 'ulimit -S -n 256; ulimit() { case "$*" in "-H -n") printf "8192\\n";; "-S -n 8192") return 1;; *) command ulimit "$@";; esac; }; ';
// Execute the production script in that same shell so the failed-set premise reaches it.
const result = spawnSync("/bin/sh", ["-c", prelude + command.args[1], ...command.args.slice(2)], { encoding: "utf8", timeout: 10_000 });
expect(result.status).toBe(0);
expect(result.stdout).toBe("ran:256:quoted ' $HOME ; new-session");
expect(result.stderr.trim()).toBe(TMUX_SERVER_LIMIT_WARNING);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
vi.resetModules();
const { reportTmuxServerLimit } = await import("../src/tmux-server-start.js");
reportTmuxServerLimit(result.stderr);
reportTmuxServerLimit(result.stderr);
expect(warn).toHaveBeenCalledExactlyOnceWith(TMUX_SERVER_LIMIT_WARNING);
} finally { warn.mockRestore(); }
});
it("keeps ordinary capture/send commands direct", () => {
for (const args of [["capture-pane", "-p"], ["send-keys", "-l", "text"], ["-V"]]) {
expect(tmuxServerStartCommand("tmux", args)).toEqual({ file: "tmux", args });
}
});
it.skipIf(!tmux)("a fresh private server inherits the raised limit; an existing one is preserved", async () => {
const dir = mkdtempSync(join(tmpdir(), "or-nofile-"));
const socket = join(dir, "socket");
const limit = join(dir, "limit");
const query = (...args: string[]) => execFileSync("tmux", ["-S", socket, ...args], { encoding: "utf8", timeout: 5000 });
try {
const start = shell('ulimit -S -n 256; ' + TMUX_SERVER_LIMIT_PREFIX + 'exec tmux -S "$1" -f /dev/null new-session -d -s owned /bin/sh -c \'ulimit -S -n > "$1"; sleep 30\' pane "$2"', [socket, limit]);
expect(start.status, start.stderr).toBe(0);
let actual = "";
for (let i = 0; i < 100; i++) {
try { actual = readFileSync(limit, "utf8").trim(); if (actual) break; } catch { /* Pane is starting. */ }
await new Promise(resolve => setTimeout(resolve, 10));
}
const hard = shell('ulimit -H -n').stdout.trim();
expect(Number(actual)).toBe(Math.min(hard === "unlimited" ? Infinity : Number(hard), 8192));
const pid = query("display-message", "-p", "#{pid}").trim();
const again = shell('ulimit -S -n 256; ' + TMUX_SERVER_LIMIT_PREFIX + 'exec tmux -S "$1" new-session -d -s another sleep 30', [socket]);
expect(again.status, again.stderr).toBe(0);
expect(query("display-message", "-p", "#{pid}").trim()).toBe(pid);
expect(query("list-sessions", "-F", "#{session_name}")).toContain("owned");
} finally {
try { query("kill-server"); } catch { /* Only this test's exact private socket. */ }
rmSync(dir, { recursive: true, force: true });
}
});
});