mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
fix: adopted tmux targets, hung servers and the attach chain (release review)
Names tmux reads as target syntax (`@1`, `%1`, `=eq`, a bare `~`) passed the has-session guard by name and then joined something else in new-session -t: another session, or a fresh shell on the foreign server. They are refused at discovery now, and take the existing skipped note. One stopped tmux server emptied the whole local list and left a stuck client per scan. Each server now gets a bounded ping with stdout at /dev/null first, because a tmux client hands dups of its stdout to the server: a stopped server holds our pipe open after the client is dead, so a watchdog around list-panes cannot work. The probe runs in its own process group, all three transports keep the rows read before a failure, and a missed ping becomes a note. The attach and the probe pass -u, so a POSIX-locale container no longer garbles the tab or lists a non-ASCII name as underscores. A docker attach gets the same TERM and COLORTERM an owned container's exec gets. A missing tmux no longer reads as "session is gone", and the launch line's `$` now reaches bash unexpanded, so the ssh attach's PATH line is expanded at the far end, as the probe's already was. fix-plan/write, fix-plan/read and ralph-prompt/write refuse an adopted session: its workingDir is the neutral /tmp, so they read and wrote a predictable name in a folder anyone can write to. FOREIGN_MAX_SOCKETS is gone: it capped nothing, and a real cap in glob order would never reach `default` on a box with many `codeman-*` sockets (this one has 212). Closing an adopted tab re-reads the outside-sessions list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
36d8942744
commit
aa40cca343
9 files changed
+620
-81
No files matched your search
@@ -3,9 +3,12 @@
|
||||
* outside Codeman).
|
||||
*
|
||||
* Two facts drive every number here. First, the number of tmux sockets and panes
|
||||
* on a machine is NOT under Codeman's control — a discovery walk with no ceiling
|
||||
* is an unbounded loop over data someone else produces, so sockets and panes are
|
||||
* both hard-capped. Second, an ssh handshake is an order of magnitude slower than
|
||||
* on a machine is NOT under Codeman's control, so a discovery walk with no ceiling
|
||||
* is an unbounded loop over data someone else produces. Parsed rows are capped by
|
||||
* count and the socket sweep by time: a sweep capped by count would stop in glob
|
||||
* order, where `codeman*` sorts before `default` and a machine running many
|
||||
* instances would never reach the sessions this feature is for. Second, an ssh
|
||||
* handshake is an order of magnitude slower than
|
||||
* a local `exec`; reusing the shared 5s `EXEC_TIMEOUT_MS` would classify every
|
||||
* remote host as unreachable, so the probe gets its own timeout.
|
||||
*
|
||||
@@ -25,8 +28,15 @@ export const FOREIGN_CACHE_TTL_MS = 5000;
|
||||
/** Timeout for one probe invocation (local exec, `docker exec`, or one ssh). */
|
||||
export const FOREIGN_PROBE_TIMEOUT_MS = 12000;
|
||||
|
||||
/** Max tmux sockets inspected per location, oldest-first by directory order. */
|
||||
export const FOREIGN_MAX_SOCKETS = 16;
|
||||
/**
|
||||
* How long one tmux server may take to answer the probe's ping, in the script's own
|
||||
* `sleep`, so whole seconds. A healthy server takes milliseconds. One that misses this
|
||||
* costs only its own rows, and the sweep moves on to the next socket.
|
||||
*/
|
||||
export const FOREIGN_SOCKET_TIMEOUT_SECONDS = 2;
|
||||
|
||||
/** How long a timed-out probe's process group gets on SIGTERM before SIGKILL. */
|
||||
export const FOREIGN_PROBE_KILL_GRACE_MS = 2000;
|
||||
|
||||
/** Max pane rows parsed from one probe. Panes past this are dropped, not errors. */
|
||||
export const FOREIGN_MAX_PANES = 400;
|
||||
|
||||
+124
-44
@@ -15,8 +15,8 @@
|
||||
* ## ⚠️ The probe must not cross an extra shell
|
||||
*
|
||||
* The script contains `$TMUX_TMPDIR` and `$(id -u)`, which the INNER shell has to
|
||||
* expand. Local and docker therefore spawn with an argv array (`execFile`), where
|
||||
* no outer shell exists to eat them first. Remote is the exception: the ssh
|
||||
* expand. Local and docker therefore spawn with an argv array, where no outer
|
||||
* shell exists to eat them first. Remote is the exception: the ssh
|
||||
* command line is a string like every other ssh call in this codebase, so the
|
||||
* script is wrapped in SINGLE quotes there — which is exactly why
|
||||
* `buildForeignProbeScript()` may not contain one.
|
||||
@@ -26,11 +26,19 @@
|
||||
* inner loop iterating over the literal `/tmux-/*` and reporting zero sessions on
|
||||
* a machine that had three.
|
||||
*
|
||||
* ## A probe that goes wrong still answers
|
||||
*
|
||||
* All three transports go through `runForeignProbe`, which never rejects and hands
|
||||
* back what it read. A timeout has to reach the whole process tree, not just the
|
||||
* `sh` on top (the same rule as `runFetch` in git-fetch.ts): signalling only `sh`
|
||||
* left its tmux child stuck on a stopped server, one more per scan, for as long as
|
||||
* the home screen stayed open. And a scan cut short keeps the rows it already has,
|
||||
* since one bad server must not empty the list of a machine with good ones.
|
||||
*
|
||||
* @module foreign-tmux-discovery
|
||||
*/
|
||||
|
||||
import { execFile, exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import {
|
||||
buildForeignProbeScript,
|
||||
classifyForeignPaneMode,
|
||||
@@ -45,20 +53,93 @@ import { dockerEngineArgv, readDockerCases, readDockerHosts } from './docker-hos
|
||||
import { buildSshConnectionArgs, readRemoteHosts, remoteSshTarget, shellescape } from './remote-hosts.js';
|
||||
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
|
||||
import { resolveTmuxSocketName, getDataDir } from './config/instance.js';
|
||||
import { FOREIGN_CACHE_TTL_MS, FOREIGN_PROBE_MAX_BYTES, FOREIGN_PROBE_TIMEOUT_MS } from './config/foreign-tmux.js';
|
||||
import {
|
||||
FOREIGN_CACHE_TTL_MS,
|
||||
FOREIGN_PROBE_KILL_GRACE_MS,
|
||||
FOREIGN_PROBE_MAX_BYTES,
|
||||
FOREIGN_PROBE_TIMEOUT_MS,
|
||||
} from './config/foreign-tmux.js';
|
||||
import type { ForeignDiscoveryResult, ForeignTmuxSession } from './types/foreign-tmux.js';
|
||||
import type { DockerCase, DockerHost, RemoteHost, SessionDocker } from './types/session.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const IS_TEST_MODE = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
|
||||
|
||||
const PROBE_OPTS = {
|
||||
encoding: 'utf-8' as const,
|
||||
timeout: FOREIGN_PROBE_TIMEOUT_MS,
|
||||
maxBuffer: FOREIGN_PROBE_MAX_BYTES,
|
||||
};
|
||||
/** What one probe produced, however it ended. */
|
||||
export interface ForeignProbeRun {
|
||||
/** Everything read before it finished or was stopped. */
|
||||
stdout: string;
|
||||
/** False when it timed out, flooded, exited non-zero or could not start. */
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one probe in its own process group. Never rejects (see @fileoverview).
|
||||
*
|
||||
* The caller is answered AT the deadline, while the group is still dying.
|
||||
*/
|
||||
export function runForeignProbe(
|
||||
argv: readonly string[],
|
||||
opts: { timeoutMs?: number; maxBytes?: number } = {}
|
||||
): Promise<ForeignProbeRun> {
|
||||
const { timeoutMs = FOREIGN_PROBE_TIMEOUT_MS, maxBytes = FOREIGN_PROBE_MAX_BYTES } = opts;
|
||||
return new Promise((resolve) => {
|
||||
const [file, ...args] = argv;
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
let settled = false;
|
||||
let child: ChildProcess | undefined;
|
||||
let escalation: NodeJS.Timeout | undefined;
|
||||
|
||||
const finish = (ok: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(deadline);
|
||||
resolve({ stdout: Buffer.concat(chunks).toString('utf-8'), ok });
|
||||
};
|
||||
const signalGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
if (child?.pid) process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
};
|
||||
const stop = () => {
|
||||
signalGroup('SIGTERM');
|
||||
escalation = setTimeout(() => signalGroup('SIGKILL'), FOREIGN_PROBE_KILL_GRACE_MS);
|
||||
escalation.unref();
|
||||
// ⚠️ Let go of the pipe by hand. A stopped tmux server can hold it open with
|
||||
// everything in this group dead (see foreign-tmux.ts), so waiting for its EOF
|
||||
// would keep one handle per scan until that server is resumed.
|
||||
child?.stdout?.destroy();
|
||||
finish(false);
|
||||
};
|
||||
const deadline = setTimeout(stop, timeoutMs);
|
||||
|
||||
try {
|
||||
child = spawn(file ?? '', args, { stdio: ['ignore', 'pipe', 'ignore'], detached: true });
|
||||
} catch {
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
if (settled) return;
|
||||
chunks.push(chunk.subarray(0, Math.max(0, maxBytes - bytes)));
|
||||
bytes += chunk.length;
|
||||
if (bytes > maxBytes) stop();
|
||||
});
|
||||
child.on('error', () => finish(false));
|
||||
child.on('close', (code) => {
|
||||
// The group is gone, so a late SIGKILL could only reach whoever reused its id.
|
||||
clearTimeout(escalation);
|
||||
finish(code === 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** The note for a probe that ended badly. Rows already read stay, so say which case this is. */
|
||||
function probeFailureNote(where: string, run: ForeignProbeRun, nothing: string): string {
|
||||
return run.stdout ? `${where}: the scan did not finish, this list may be incomplete` : `${where}: ${nothing}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn one probe's stdout into candidates. Shared by all three transports, so a
|
||||
@@ -68,7 +149,7 @@ const PROBE_OPTS = {
|
||||
* <label>`). The browser tells the two kinds apart by it: a local note is re-read on
|
||||
* every poll, a container or host note only by the next explicit scan.
|
||||
*/
|
||||
function candidatesFrom(
|
||||
export function candidatesFrom(
|
||||
stdout: string,
|
||||
location: ForeignTmuxSession['location'],
|
||||
hostKey: string,
|
||||
@@ -119,9 +200,16 @@ function candidatesFrom(
|
||||
}
|
||||
if (unsafe) {
|
||||
notes.push(
|
||||
`${where}: ${unsafe} session(s) skipped, the tmux session name or socket path contains characters Codeman will not pass to a shell`
|
||||
`${where}: ${unsafe} session(s) skipped, the tmux session name or socket path is one Codeman cannot pass safely to a shell or to tmux`
|
||||
);
|
||||
}
|
||||
// Another Codeman's server is not a session this list would ever have shown.
|
||||
const hung = probe.hungSockets.filter(
|
||||
(socketPath) => !isCodemanOwnedPane({ socketPath, sessionName: '' }, ownSocket)
|
||||
);
|
||||
if (hung.length) {
|
||||
notes.push(`${where}: ${hung.length} tmux server(s) did not answer, sessions on them are not listed`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -145,17 +233,11 @@ export async function discoverLocalForeign(force = false): Promise<ForeignDiscov
|
||||
|
||||
localInFlight = (async (): Promise<ForeignDiscoveryResult> => {
|
||||
const notes: string[] = [];
|
||||
let sessions: ForeignTmuxSession[] = [];
|
||||
try {
|
||||
// argv array: no outer shell, so the script's own `$TMUX_TMPDIR`/`$(id -u)`
|
||||
// reach the inner sh intact (see @fileoverview).
|
||||
const { stdout } = await execFileAsync('sh', ['-c', buildForeignProbeScript()], PROBE_OPTS);
|
||||
sessions = candidatesFrom(stdout, 'local', 'local', {}, 'local', notes);
|
||||
} catch {
|
||||
// No tmux, no sockets, or a probe that timed out. "Nothing to adopt here"
|
||||
// is the only honest answer and it is never an error to the caller.
|
||||
notes.push('local: tmux probe produced no result');
|
||||
}
|
||||
// argv array: no outer shell, so the script's own `$TMUX_TMPDIR`/`$(id -u)`
|
||||
// reach the inner sh intact (see @fileoverview).
|
||||
const run = await runForeignProbe(['sh', '-c', buildForeignProbeScript()]);
|
||||
const sessions = candidatesFrom(run.stdout, 'local', 'local', {}, 'local', notes);
|
||||
if (!run.ok) notes.push(probeFailureNote('local', run, 'tmux probe produced no result'));
|
||||
const result: ForeignDiscoveryResult = { sessions, scannedAt: Date.now(), notes };
|
||||
localCache = { at: Date.now(), result };
|
||||
return result;
|
||||
@@ -239,22 +321,19 @@ export async function discoverDockerForeign(cases: DockerCase[]): Promise<Foreig
|
||||
|
||||
await Promise.all(
|
||||
targets.map(async (t) => {
|
||||
const [engine, ...args] = foreignDockerProbeArgv(t);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(engine, args, PROBE_OPTS);
|
||||
sessions.push(
|
||||
...candidatesFrom(
|
||||
stdout,
|
||||
'docker',
|
||||
`${t.hostId} ${t.containerName}`,
|
||||
{ hostId: t.hostId, hostLabel: t.label, containerName: t.containerName },
|
||||
`docker ${t.containerName}`,
|
||||
notes
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
notes.push(`docker ${t.containerName}: not running, no tmux, or engine unreachable`);
|
||||
}
|
||||
const where = `docker ${t.containerName}`;
|
||||
const run = await runForeignProbe(foreignDockerProbeArgv(t));
|
||||
sessions.push(
|
||||
...candidatesFrom(
|
||||
run.stdout,
|
||||
'docker',
|
||||
`${t.hostId} ${t.containerName}`,
|
||||
{ hostId: t.hostId, hostLabel: t.label, containerName: t.containerName },
|
||||
where,
|
||||
notes
|
||||
)
|
||||
);
|
||||
if (!run.ok) notes.push(probeFailureNote(where, run, 'not running, no tmux, or engine unreachable'));
|
||||
})
|
||||
);
|
||||
|
||||
@@ -301,12 +380,13 @@ export async function discoverRemoteForeign(hosts: RemoteHost[]): Promise<Foreig
|
||||
hosts.map(async (host) => {
|
||||
const where = `remote ${host.label}`;
|
||||
try {
|
||||
const { stdout } = await runWithRemoteSshLimit(() => execAsync(foreignRemoteProbeCommand(host), PROBE_OPTS), {
|
||||
const run = await runWithRemoteSshLimit(() => runForeignProbe(['sh', '-c', foreignRemoteProbeCommand(host)]), {
|
||||
maxWaitMs: FOREIGN_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
sessions.push(
|
||||
...candidatesFrom(stdout, 'remote', host.id, { hostId: host.id, hostLabel: host.label }, where, notes)
|
||||
...candidatesFrom(run.stdout, 'remote', host.id, { hostId: host.id, hostLabel: host.label }, where, notes)
|
||||
);
|
||||
if (!run.ok) notes.push(probeFailureNote(where, run, 'unreachable, or no tmux'));
|
||||
} catch {
|
||||
notes.push(`${where}: unreachable, or no tmux`);
|
||||
}
|
||||
|
||||
+102
-14
@@ -14,6 +14,21 @@
|
||||
* is pure and unit-testable, instead of in a shell string that is embedded three
|
||||
* different ways and can only be debugged against a real host.
|
||||
*
|
||||
* The one thing the script does decide is whether a server is safe to ask, because
|
||||
* only the script can. A tmux server that stopped answering holds its client
|
||||
* forever, and from outside a container or across ssh no signal reaches that
|
||||
* client: a timeout kills `docker exec` or `ssh`, never what they started. So each
|
||||
* server gets a bounded ping first, and one that misses it costs its own rows and
|
||||
* a `CMFH` line, not the whole scan.
|
||||
*
|
||||
* ⚠️ The ping exists because a watchdog around `list-panes` cannot work. A tmux
|
||||
* client hands dups of its stdout to the server, so a stopped server keeps our
|
||||
* stdout pipe open after the client is dead (measured: SIGTERM ended the client at
|
||||
* once and no EOF came in the 2.5 s that were watched, while the server stayed
|
||||
* stopped). Nothing reading that pipe can finish, and the same hold would keep
|
||||
* `docker exec` and `ssh` waiting on it. The ping's stdout is /dev/null, so only a
|
||||
* server that just answered ever gets the real one.
|
||||
*
|
||||
* ⚠️ The script MUST NOT contain a single quote. It is wrapped in single quotes
|
||||
* to cross `ssh <host> '<script>'` and `docker exec <c> sh -lc '<script>'`. That
|
||||
* is the single-quote dual of the "no double quotes in the docker launch chain"
|
||||
@@ -71,7 +86,7 @@
|
||||
import { collectDescendants } from './proc-tree.js';
|
||||
import { listClis } from './config/cli-registry/registry.js';
|
||||
import { withRemoteTmuxPath } from './remote-hosts.js';
|
||||
import { FOREIGN_MAX_PANES, FOREIGN_MAX_PROCS, FOREIGN_MAX_SOCKETS } from './config/foreign-tmux.js';
|
||||
import { FOREIGN_MAX_PANES, FOREIGN_MAX_PROCS, FOREIGN_SOCKET_TIMEOUT_SECONDS } from './config/foreign-tmux.js';
|
||||
import type { ForeignPaneProbe, ForeignTmuxLocationKind } from './types/foreign-tmux.js';
|
||||
import type { SessionAdopt, SessionMode } from './types/session.js';
|
||||
|
||||
@@ -92,6 +107,7 @@ const REJOIN = '\t';
|
||||
const PANE_MARKER = 'CMFP';
|
||||
const PROC_MARKER = 'CMFQ';
|
||||
const SOCKET_MARKER = 'CMFS';
|
||||
const HUNG_MARKER = 'CMFH';
|
||||
|
||||
/**
|
||||
* The probe, as POSIX sh. NO SINGLE QUOTES (see @fileoverview).
|
||||
@@ -99,6 +115,7 @@ const SOCKET_MARKER = 'CMFS';
|
||||
* Emits, in order:
|
||||
* CMFS<sep><socket path> (one per readable socket)
|
||||
* CMFP<sep><socket><sep>...<sep>name<sep>path (one per pane on that socket)
|
||||
* CMFH<sep><socket path> (a server that did not answer)
|
||||
* CMFQ (marker; ps rows follow)
|
||||
* <pid> <ppid> <argv...>
|
||||
*
|
||||
@@ -135,11 +152,32 @@ export function buildForeignProbeScript(): string {
|
||||
// server (that is exactly where tmux puts them). A socket at an arbitrary
|
||||
// `tmux -S /custom/path` is deliberately out of scope: finding it would mean
|
||||
// walking the filesystem for sockets.
|
||||
//
|
||||
// `-u` because a POSIX locale otherwise rewrites every non-ASCII byte tmux prints
|
||||
// to `_` (measured in a container: session `会话` listed as `____`), and a name
|
||||
// read wrong is a name the guard then cannot find.
|
||||
//
|
||||
// The ping (see @fileoverview) runs in the background beside a watchdog, the POSIX
|
||||
// spelling of `timeout`, which macOS does not ship. A stale socket answers at once
|
||||
// with status 1 and is skipped, so the ping costs a live server one extra spawn and
|
||||
// a dead one none. `has-session` cannot start a server on a stale socket.
|
||||
// ⚠️ The watchdog says it fired with its own exit status, 7: a tmux client ended by
|
||||
// SIGTERM exits 0 (measured), so the ping's status cannot tell. It prints the marker
|
||||
// BEFORE the kill and the loop reaps it, or the line could land after `CMFQ` and
|
||||
// parse as a process row. The `trap` ends the `sleep` with it, so a scan does not
|
||||
// leave one behind per socket. `\$Z` is for the trap to expand: it is set before Z
|
||||
// exists, so a socket that answers at once wins that race.
|
||||
const watchdog =
|
||||
`( trap "kill \\$Z 2>/dev/null; exit" TERM; sleep ${FOREIGN_SOCKET_TIMEOUT_SECONDS} >/dev/null 2>&1 & Z=$!; ` +
|
||||
`wait $Z && { echo "${HUNG_MARKER} $S"; kill $P; exit 7; } ) & W=$!`;
|
||||
const ping =
|
||||
`tmux -S "$S" has-session >/dev/null 2>&1 & P=$!; ${watchdog}; ` +
|
||||
'wait $P; R=$?; kill $W 2>/dev/null; wait $W 2>/dev/null; [ $? -ne 7 ] && [ $R -eq 0 ] || continue';
|
||||
return [
|
||||
'TD=${TMUX_TMPDIR:-/tmp}',
|
||||
'U=$(id -u 2>/dev/null || echo 0)',
|
||||
`for S in $TD/tmux-$U/*; do [ -S "$S" ] || continue; echo "${SOCKET_MARKER} $S"; ` +
|
||||
`tmux -S "$S" list-panes -a -F "${fmt}" 2>/dev/null; done`,
|
||||
`for S in $TD/tmux-$U/*; do [ -S "$S" ] || continue; echo "${SOCKET_MARKER} $S"; ${ping}; ` +
|
||||
`tmux -u -S "$S" list-panes -a -F "${fmt}" 2>/dev/null; done`,
|
||||
`echo ${PROC_MARKER}`,
|
||||
'ps -eo pid=,ppid=,args= 2>/dev/null || ps ax -o pid=,ppid=,args= 2>/dev/null || true',
|
||||
].join('; ');
|
||||
@@ -157,15 +195,18 @@ export interface ForeignProbeOutput {
|
||||
argvByPid: Map<number, string>;
|
||||
/** Sockets seen, in scan order (may exceed the pane list when a socket is empty). */
|
||||
sockets: string[];
|
||||
/** Sockets whose server missed the per-socket bound, so their rows are absent. */
|
||||
hungSockets: string[];
|
||||
}
|
||||
|
||||
/** Parse one probe's stdout. Never throws: a malformed row is skipped, not fatal. */
|
||||
export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
|
||||
const panes: ForeignPaneProbe[] = [];
|
||||
const sockets: string[] = [];
|
||||
const hungSockets: string[] = [];
|
||||
const byParent = new Map<number, number[]>();
|
||||
const argvByPid = new Map<number, string>();
|
||||
if (!stdout) return { panes, byParent, argvByPid, sockets };
|
||||
if (!stdout) return { panes, byParent, argvByPid, sockets, hungSockets };
|
||||
|
||||
let inProcs = false;
|
||||
let procCount = 0;
|
||||
@@ -197,7 +238,12 @@ export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
|
||||
|
||||
if (line.startsWith(`${SOCKET_MARKER} `)) {
|
||||
const sock = line.slice(SOCKET_MARKER.length + 1);
|
||||
if (sock && sockets.length < FOREIGN_MAX_SOCKETS && !sockets.includes(sock)) sockets.push(sock);
|
||||
if (sock && !sockets.includes(sock)) sockets.push(sock);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(`${HUNG_MARKER} `)) {
|
||||
hungSockets.push(line.slice(HUNG_MARKER.length + 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -233,7 +279,7 @@ export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
|
||||
});
|
||||
}
|
||||
|
||||
return { panes, byParent, argvByPid, sockets };
|
||||
return { panes, byParent, argvByPid, sockets, hungSockets };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -465,6 +511,18 @@ export function isCodemanOwnedPane(
|
||||
*/
|
||||
const ADOPTABLE_TEXT = /^[\p{L}\p{N}\p{M} _.+=@#%,~^!/-]+$/u;
|
||||
|
||||
/**
|
||||
* Names `new-session -t` reads as target syntax instead of as a name.
|
||||
*
|
||||
* ⚠️ The guard's `has-session -t '=<name>'` looks up by name, the join does not, so
|
||||
* these pass the guard and then join something else. Measured on tmux 3.4: `@1` and
|
||||
* `%1` joined the session holding that window or pane, and input typed in the tab
|
||||
* reached it; `=eq`, `@zz`, `%zz`, `~` and a bare `=` made a new group with a fresh
|
||||
* shell on the foreign server. `~foo` and `! + - ^` joined their own windows, so a
|
||||
* plain first-character rule would refuse them for nothing.
|
||||
*/
|
||||
const TMUX_TARGET_SYNTAX = /^[=@%]|^~$/;
|
||||
|
||||
/** Same rule, plus the path separator and no `..` climbing. */
|
||||
export function isAdoptableSocketPath(socketPath: string): boolean {
|
||||
if (!socketPath || socketPath.length > 4096) return false;
|
||||
@@ -476,6 +534,7 @@ export function isAdoptableSocketPath(socketPath: string): boolean {
|
||||
/** True when this session name can cross the launch chain safely. */
|
||||
export function isAdoptableSessionName(sessionName: string): boolean {
|
||||
if (!sessionName || sessionName.length > 256) return false;
|
||||
if (TMUX_TARGET_SYNTAX.test(sessionName)) return false;
|
||||
return ADOPTABLE_TEXT.test(sessionName);
|
||||
}
|
||||
|
||||
@@ -583,10 +642,18 @@ export interface ForeignAttachTarget {
|
||||
* The tmux invocation that joins a foreign session, as ONE line of POSIX sh.
|
||||
*
|
||||
* Shape and why:
|
||||
* command -v tmux || { echo no tmux; exit 127; } ;
|
||||
* tmux -S <sock> has-session -t '=<target>' || { echo gone; exit 1; } ;
|
||||
* tmux -S <sock> kill-session -t '=<view>' 2>/dev/null ;
|
||||
* exec tmux -S <sock> new-session -t <target> -s <view> ; set destroy-unattached on ; set status off
|
||||
* exec tmux -u -S <sock> new-session -t <target> -s <view> ; set destroy-unattached on ; set status off
|
||||
*
|
||||
* - ⚠️ `command -v` first because the guard below fires on ANY failure, and "tmux is
|
||||
* not on this PATH" (exit 127, hidden by the `2>/dev/null`) must not be told to the
|
||||
* user as "your session is gone" while it is running.
|
||||
* - `-u` on the client: in a POSIX locale it otherwise writes the tab as if the
|
||||
* terminal were ASCII (measured: `_ hello qqq ____` for `❯ hello ─── 你好`). Only
|
||||
* the join needs it, since the guard prints nothing and tmux compares a target
|
||||
* name as bytes in any locale.
|
||||
* - ⚠️ The `has-session` guard comes FIRST because `new-session -t` does not fail
|
||||
* on a missing target, it invents one. Measured on tmux 3.4: with `work` gone it
|
||||
* joined `work-prod`, with nothing matching it created a fresh shell, and on a
|
||||
@@ -595,10 +662,12 @@ export interface ForeignAttachTarget {
|
||||
* owner exits their session the next Codeman restart would leave a new shell on
|
||||
* their machine, in their container or on their ssh host. `=` is what makes the
|
||||
* check exact (no prefix match), and `2>/dev/null` keeps tmux's own "no server"
|
||||
* noise out of the pane — the message we print says it better.
|
||||
* - ⚠️ The `=` prefix cannot go on `new-session -t` itself: tmux reads it as part of
|
||||
* the name and creates a group literally called `=work`. It has to be a separate
|
||||
* `has-session`.
|
||||
* noise out of the pane: the message we print says it better. The check only holds
|
||||
* for a name `new-session -t` also reads as a name, which is what
|
||||
* `isAdoptableSessionName` keeps true (see `TMUX_TARGET_SYNTAX`).
|
||||
* - ⚠️ The `=` prefix cannot stand in for that check on `new-session -t`: with the
|
||||
* target gone tmux takes it as part of the name and creates a group literally
|
||||
* called `=work`. It has to be a separate `has-session`.
|
||||
* - `new-session -t` creates a session in the target's GROUP: same windows, our
|
||||
* own session options and current window (NOT our own size — see @fileoverview
|
||||
* for the measurement that disproved that).
|
||||
@@ -646,14 +715,30 @@ export function buildForeignTmuxInvocation(target: ForeignAttachTarget): string
|
||||
const gone = fshq(
|
||||
`Codeman: session ${target.targetSession} is gone. Codeman never creates a session it does not own.`
|
||||
);
|
||||
const noTmux = fshq('Codeman: tmux was not found here, so the session could not be opened. It may still be running.');
|
||||
const tmuxCheck = `command -v tmux >/dev/null 2>&1 || { echo ${noTmux}; exit 127; }`;
|
||||
const guard = `tmux -S ${sock} has-session -t ${exact} 2>/dev/null || { echo ${gone}; exit 1; }`;
|
||||
// Our own leftover view from a previous adoption of the same Codeman session.
|
||||
const dropStaleView = `tmux -S ${sock} kill-session -t ${fshq(`=${target.viewSession}`)} 2>/dev/null || true`;
|
||||
const group =
|
||||
`exec tmux -S ${sock} new-session -t ${t} -s ${v} \\; ` +
|
||||
`exec tmux -u -S ${sock} new-session -t ${t} -s ${v} \\; ` +
|
||||
`set-option -t ${v} destroy-unattached on \\; ` +
|
||||
`set-option -t ${v} status off`;
|
||||
return `${guard} ; ${dropStaleView} ; ${group}`;
|
||||
return `${tmuxCheck} ; ${guard} ; ${dropStaleView} ; ${group}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The attach line as the `bash -c` argument of the wrapper's `respawn-pane`.
|
||||
*
|
||||
* That argument sits in double quotes in a string `execSync` hands to `sh -c`, where
|
||||
* `JSON.stringify` leaves `$` and backticks live and single quotes protect nothing.
|
||||
* The ssh attach is the line that notices: its `$HOME` and `$PATH` belong to the far
|
||||
* end (`foreignRemoteShellCommand`), and were being expanded HERE, so a tmux in the
|
||||
* remote `~/.local/bin` was found by discovery and then missed by the attach. This
|
||||
* escapes them for that one parse, so the line reaches `bash` as written.
|
||||
*/
|
||||
export function quoteAdoptLaunch(launchCmd: string): string {
|
||||
return JSON.stringify(launchCmd).replace(/[$`]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** Local adoption: run the invocation directly in the wrapper pane. */
|
||||
@@ -686,7 +771,10 @@ export function buildForeignDockerAttachCommand(opts: ForeignDockerAttachOptions
|
||||
const running =
|
||||
`${opts.dockerBase} inspect -f ${fshq('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true ` +
|
||||
`|| { echo ${notRunning}; exit 1; }`;
|
||||
const exec = `exec ${opts.dockerBase} exec -it ${name} sh -lc ${fshq(buildForeignTmuxInvocation(opts))}`;
|
||||
// The same terminal `resolveDockerLaunchOptions` gives an owned container's exec:
|
||||
// `docker exec -it` defaults to TERM=xterm, which holds the tab to 8 colours.
|
||||
const terminal = '-e TERM=xterm-256color -e COLORTERM=truecolor';
|
||||
const exec = `exec ${opts.dockerBase} exec -it ${terminal} ${name} sh -lc ${fshq(buildForeignTmuxInvocation(opts))}`;
|
||||
return [inspect, running, exec].join(' ; ');
|
||||
}
|
||||
|
||||
|
||||
+10
-13
@@ -29,6 +29,7 @@ import {
|
||||
buildForeignAttachCommand,
|
||||
buildForeignDockerAttachCommand,
|
||||
buildForeignRemoteAttachCommand,
|
||||
quoteAdoptLaunch,
|
||||
} from './foreign-tmux.js';
|
||||
import { execSync, exec, execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
@@ -2730,13 +2731,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
|
||||
// is the neutral wrapper folder, not where the foreign pane is.
|
||||
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
execSync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
{
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
}
|
||||
);
|
||||
const launchArg = adopt ? quoteAdoptLaunch(launchCmd) : JSON.stringify(launchCmd);
|
||||
execSync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${launchArg}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
// Wait for tmux session to be queryable
|
||||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||
@@ -2994,12 +2993,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
|
||||
// is the neutral wrapper folder, not where the foreign pane is.
|
||||
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
await execAsync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
{
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
);
|
||||
const launchArg = adopt ? quoteAdoptLaunch(launchCmd) : JSON.stringify(launchCmd);
|
||||
await execAsync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${launchArg}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
// Wait for the respawned process to start
|
||||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||
// The pane now runs a fresh command, so whatever the last read observed of
|
||||
|
||||
@@ -10879,6 +10879,9 @@ class CodemanApp {
|
||||
// An SSE resync (handleInit) that landed mid-request rebuilds the list from
|
||||
// the server, which still had the session then.
|
||||
if (this.sessions.has(sessionId)) this._onSessionDeleted({ id: sessionId });
|
||||
// The home screen read the outside-sessions list when the tab went, before this
|
||||
// delete landed, so its row would say "Go to tab" with no tab until the next poll.
|
||||
if (session?.adopt) void this.loadForeignSessions?.();
|
||||
if (killMux) {
|
||||
this.showToast('Session closed and tmux killed', 'success');
|
||||
} else {
|
||||
|
||||
@@ -37,6 +37,15 @@ import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from
|
||||
/** Cap on a remote `@fix_plan.md` read over ssh (a task list, not a document). */
|
||||
const MAX_REMOTE_FIX_PLAN_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* An adopted session's `workingDir` is the neutral wrapper folder `/tmp`, not a workspace.
|
||||
* Using it would write a predictable name into a folder anyone can write to, and read one
|
||||
* back, while the agent in the pane never sees either file.
|
||||
*/
|
||||
function refuseAdoptedRalphFile(file: string) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${file} is not available for an adopted session`);
|
||||
}
|
||||
|
||||
export function registerRalphRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
@@ -208,6 +217,7 @@ export function registerRalphRoutes(
|
||||
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.isAdopted) return refuseAdoptedRalphFile('@fix_plan.md');
|
||||
// A remote session's workingDir is a path on the remote host. Writing it with local fs would land
|
||||
// the file in a same-named local folder (or fail), and remote writes are out of scope by design
|
||||
// (src/remote-files.ts is read-only), so it is refused, like the File Viewer's remote edit.
|
||||
@@ -244,6 +254,7 @@ export function registerRalphRoutes(
|
||||
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.isAdopted) return refuseAdoptedRalphFile('@fix_plan.md');
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -291,6 +302,7 @@ export function registerRalphRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (session.isAdopted) return refuseAdoptedRalphFile('@ralph_prompt.md');
|
||||
// Same rule as fix-plan/write: never write a remote session's file on the local disk.
|
||||
if (session.remote) {
|
||||
return createErrorResponse(
|
||||
|
||||
+287
-4
@@ -6,8 +6,9 @@
|
||||
* would quietly undo. Each one has a comment naming what actually went wrong.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createServer, type Server } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
@@ -26,8 +27,14 @@ import {
|
||||
buildForeignDockerViewKillCommand,
|
||||
buildForeignTmuxInvocation,
|
||||
foreignCliSignatures,
|
||||
quoteAdoptLaunch,
|
||||
} from '../src/foreign-tmux.js';
|
||||
import { foreignDockerProbeArgv, foreignRemoteProbeCommand } from '../src/foreign-tmux-discovery.js';
|
||||
import {
|
||||
candidatesFrom,
|
||||
foreignDockerProbeArgv,
|
||||
foreignRemoteProbeCommand,
|
||||
runForeignProbe,
|
||||
} from '../src/foreign-tmux-discovery.js';
|
||||
import { buildAdoptSessionCommand } from '../src/tmux-manager.js';
|
||||
import type { RemoteHost, SessionAdopt } from '../src/types/session.js';
|
||||
import { PROC_WALK_MAX_NODES } from '../src/proc-tree.js';
|
||||
@@ -74,6 +81,20 @@ describe('buildForeignProbeScript', () => {
|
||||
expect(s).toContain('${TMUX_TMPDIR:-/tmp}');
|
||||
expect(s).toContain('$TD/tmux-$U/*');
|
||||
});
|
||||
|
||||
it('asks tmux for UTF-8, so a POSIX locale cannot rewrite a name into underscores', () => {
|
||||
// Measured in a POSIX-locale container: without `-u` a session called `会话` was
|
||||
// listed as `____`, the guard then looked for `____`, and the tab opened dead.
|
||||
expect(buildForeignProbeScript()).toContain('tmux -u -S "$S" list-panes');
|
||||
});
|
||||
|
||||
it('pings each server with stdout at /dev/null before it gets the real one', () => {
|
||||
// A client hands dups of its stdout to the server, so a stopped server holds our
|
||||
// pipe open after the client dies and no reader of that pipe can ever finish.
|
||||
const s = buildForeignProbeScript();
|
||||
expect(s).toContain('tmux -S "$S" has-session >/dev/null 2>&1 &');
|
||||
expect(s.indexOf('has-session')).toBeLessThan(s.indexOf('list-panes'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseForeignProbeOutput', () => {
|
||||
@@ -116,6 +137,19 @@ describe('parseForeignProbeOutput', () => {
|
||||
expect(out.panes[0].paneCurrentPath).toBe('/srv/x');
|
||||
expect(out.panes[0].sessionName).toBe('my\tname');
|
||||
});
|
||||
|
||||
it('lists every socket it swept, however many there are', () => {
|
||||
// There used to be a cap here that only trimmed this list while the loop went on
|
||||
// to every socket anyway. A real cap in glob order would be worse: `codeman*`
|
||||
// sorts before `default`, so a box with many instances would never reach it.
|
||||
const lines = Array.from({ length: 40 }, (_, i) => `CMFS /tmp/tmux-0/s${i}`);
|
||||
expect(parseForeignProbeOutput(lines.join('\n')).sockets).toHaveLength(40);
|
||||
});
|
||||
|
||||
it('records a server that did not answer', () => {
|
||||
const out = parseForeignProbeOutput(`CMFS /tmp/tmux-0/hung\nCMFH /tmp/tmux-0/hung\nCMFQ\n`);
|
||||
expect(out.hungSockets).toEqual(['/tmp/tmux-0/hung']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyForeignPaneMode', () => {
|
||||
@@ -201,6 +235,22 @@ describe('adoptability gate (security)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a name tmux reads as target syntax rather than as a name', () => {
|
||||
// `has-session -t '=<name>'` finds these by name, but `new-session -t <name>` does
|
||||
// not. Measured on tmux 3.4: `@1` and `%1` joined the session holding that window or
|
||||
// pane, so input typed in the tab reached someone else's pane, and `=eq`, `=`, `@zz`,
|
||||
// `%zz` and `~` made a new group with a fresh shell on the foreign server.
|
||||
for (const n of ['@1', '%1', '@zz', '%zz', '=eq', '=', '~']) expect(isAdoptableSessionName(n)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the names that only look like target syntax', () => {
|
||||
// Each of these joined its own windows on tmux 3.4. `! + - ^` are window tokens, and
|
||||
// a `-t` of the session type never reads them as such.
|
||||
for (const n of ['!', '+', '-', '^', '^x', '+x', '-dash', '!bang', '~foo', 'a~b', 'a@b', 'x=y', 'x%y', '40']) {
|
||||
expect(isAdoptableSessionName(n)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the socket path to the same rule, plus absoluteness', () => {
|
||||
// A socket is `tmux -L <name>` under a user-controlled directory, so its
|
||||
// path is attacker-influenceable in exactly the same way.
|
||||
@@ -280,6 +330,29 @@ describe('attach command builders', () => {
|
||||
).toContain('has-session');
|
||||
});
|
||||
|
||||
it('attaches as a UTF-8 client whatever the locale says', () => {
|
||||
// Measured in a POSIX-locale container: without `-u` the tab read `_ hello qqq ____`
|
||||
// for `❯ hello ─── 你好`, with `client_utf8=0`.
|
||||
expect(buildForeignTmuxInvocation(target)).toContain("exec tmux -u -S '/tmp/tmux-0/default' new-session -t");
|
||||
});
|
||||
|
||||
it('says tmux is missing instead of calling a live session gone', () => {
|
||||
// `has-session ... 2>/dev/null ||` fires for any failure, exit 127 included, so an
|
||||
// ssh host where tmux is off the PATH used to print that the session was gone.
|
||||
const emptyBin = mkdtempSync(join(tmpdir(), 'foreign-no-tmux-'));
|
||||
try {
|
||||
const run = spawnSync('/bin/sh', ['-c', buildForeignTmuxInvocation(target)], {
|
||||
encoding: 'utf-8',
|
||||
env: { PATH: emptyBin },
|
||||
});
|
||||
expect(run.status).toBe(127);
|
||||
expect(run.stdout).toMatch(/tmux was not found/);
|
||||
expect(run.stdout).not.toMatch(/is gone/);
|
||||
} finally {
|
||||
rmSync(emptyBin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('sets destroy-unattached so the view dies with our pane', () => {
|
||||
expect(buildForeignTmuxInvocation(target)).toContain('destroy-unattached on');
|
||||
});
|
||||
@@ -372,6 +445,11 @@ describe('attach command builders', () => {
|
||||
it('carries the engine flags it was given', () => {
|
||||
expect(cmd).toContain('docker --context ci');
|
||||
});
|
||||
|
||||
it('gives the client the same terminal an owned container gets', () => {
|
||||
// `docker exec -it` defaults to TERM=xterm, which held the tab to 8 colours.
|
||||
expect(cmd).toContain("exec -it -e TERM=xterm-256color -e COLORTERM=truecolor 'my-box' sh -lc");
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote', () => {
|
||||
@@ -478,7 +556,8 @@ describe('the docker and ssh transports', () => {
|
||||
},
|
||||
'abc12345'
|
||||
);
|
||||
expect(cmd).toContain("podman --context 'my ctx' exec -it 'box' sh -lc");
|
||||
expect(cmd).toContain("podman --context 'my ctx' exec -it ");
|
||||
expect(cmd).toContain("'box' sh -lc");
|
||||
});
|
||||
|
||||
it('goes through the saved connection for an ssh host', () => {
|
||||
@@ -534,6 +613,210 @@ describe('the docker and ssh transports', () => {
|
||||
expect(out).toContain('<-S></tmp/tmux-1000/my sock><has-session><-t><=my work>');
|
||||
expect(out).toContain('<new-session><-t><my work><-s><codeman-view-abc12345><;><set-option>');
|
||||
});
|
||||
|
||||
it('leaves the PATH line for the far end to expand', () => {
|
||||
// The launch string crosses execSync's `sh -c` inside double quotes, where single
|
||||
// quotes protect nothing, so `$HOME` and `$PATH` used to become THIS host's values
|
||||
// and a tmux in the remote `~/.local/bin` was found by discovery and missed here.
|
||||
writeFileSync(join(bin, 'ssh'), '#!/bin/sh\nfor last; do :; done\nprintf "%s\\n" "$last"\n', { mode: 0o755 });
|
||||
const launch = buildForeignRemoteAttachCommand({
|
||||
socketPath: '/tmp/tmux-1000/default',
|
||||
targetSession: 'work',
|
||||
viewSession: 'codeman-view-abc12345',
|
||||
sshArgs: ['ssh', '-o BatchMode=yes'],
|
||||
sshTarget: 'me@host',
|
||||
});
|
||||
const out = execSync(`bash -c ${quoteAdoptLaunch(launch)}`, {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
|
||||
});
|
||||
expect(out).toContain('$HOME/.local/bin');
|
||||
expect(out).toContain(':$PATH"');
|
||||
expect(out).not.toContain(home);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quoteAdoptLaunch', () => {
|
||||
const echoed = (text: string) => execSync(`printf '%s' ${quoteAdoptLaunch(text)}`, { encoding: 'utf-8' });
|
||||
|
||||
it('hands the text through one sh parse untouched', () => {
|
||||
const text = 'echo $HOME `id` "q" \\ and \\$x';
|
||||
expect(echoed(text)).toBe(text);
|
||||
});
|
||||
|
||||
it('is what both launch sites use', () => {
|
||||
// Create and respawn build the same string. Fixing only one would leave a wrapper
|
||||
// whose first attach works and whose re-attach after a restart does not.
|
||||
const src = readFileSync(join(import.meta.dirname, '../src/tmux-manager.ts'), 'utf-8');
|
||||
expect(src.match(/adopt \? quoteAdoptLaunch\(launchCmd\) : JSON\.stringify\(launchCmd\)/g)).toHaveLength(2);
|
||||
expect(src.match(/bash -c \$\{JSON\.stringify\(launchCmd\)\}/g)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('a tmux server that stops answering', () => {
|
||||
// A real server SIGSTOPped on a scratch socket cost 12 s, every row on the machine and
|
||||
// a stuck client per scan. The stand-in tmux below hangs the same way for one socket.
|
||||
// /tmp rather than tmpdir(): a unix socket path has to fit in about 100 bytes.
|
||||
let root: string;
|
||||
let bin: string;
|
||||
let servers: Server[];
|
||||
|
||||
const listen = (path: string) =>
|
||||
new Promise<Server>((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(path, () => resolve(server));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
root = mkdtempSync('/tmp/cmfp-');
|
||||
bin = join(root, 'bin');
|
||||
const sockets = join(root, `tmux-${process.getuid?.() ?? 0}`);
|
||||
mkdirSync(bin);
|
||||
mkdirSync(sockets);
|
||||
servers = await Promise.all(['a', 'hung', 'z'].map((name) => listen(join(sockets, name))));
|
||||
writeFileSync(
|
||||
join(bin, 'tmux'),
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`echo "$*" >> ${root}/calls`,
|
||||
'case "$*" in *"/hung "*) exec sleep 30 ;; esac',
|
||||
'case "$*" in *has-session*) exit 0 ;; esac',
|
||||
'for a; do case "$a" in */tmux-*) S=$a ;; esac; done',
|
||||
`printf 'CMFP\\t%s\\t7\\t0\\t1\\t100\\t0\\t%%%%0\\tbash\\twork\\t/srv\\n' "$S"`,
|
||||
].join('\n'),
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.map((s) => new Promise((resolve) => s.close(resolve))));
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const probe = (script: string) =>
|
||||
runForeignProbe(['env', `TMUX_TMPDIR=${root}`, `PATH=${bin}:${process.env.PATH}`, 'sh', '-c', script]);
|
||||
|
||||
it('costs only its own rows, and the scan still finishes', async () => {
|
||||
const startedAt = Date.now();
|
||||
const run = await probe(buildForeignProbeScript());
|
||||
expect(Date.now() - startedAt).toBeLessThan(6000);
|
||||
expect(run.ok).toBe(true);
|
||||
const out = parseForeignProbeOutput(run.stdout);
|
||||
expect(out.panes.map((p) => p.socketPath.split('/').pop())).toEqual(['a', 'z']);
|
||||
expect(out.hungSockets.map((s) => s.split('/').pop())).toEqual(['hung']);
|
||||
// The script ran on past the hung socket to the marker that ends the sweep.
|
||||
expect(run.stdout).toContain('CMFQ');
|
||||
// ⚠️ The point of the ping: a server that missed it never receives our stdout.
|
||||
const calls = readFileSync(join(root, 'calls'), 'utf-8');
|
||||
expect(calls).toMatch(/\/hung has-session/);
|
||||
expect(calls).not.toMatch(/\/hung list-panes/);
|
||||
});
|
||||
|
||||
it('never lists, let alone starts, a server on a stale socket', async () => {
|
||||
writeFileSync(
|
||||
join(bin, 'tmux'),
|
||||
`#!/bin/sh\necho "$*" >> ${root}/calls\ncase "$*" in *has-session*) exit 1 ;; esac\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
await probe(buildForeignProbeScript());
|
||||
const calls = readFileSync(join(root, 'calls'), 'utf-8');
|
||||
expect(calls).toMatch(/has-session/);
|
||||
expect(calls).not.toMatch(/list-panes|new-session|start-server/);
|
||||
});
|
||||
|
||||
it('says so in a note, and keeps the rows it did read', () => {
|
||||
const stdout = [
|
||||
'CMFS /tmp/tmux-0/default',
|
||||
'CMFP\\t/tmp/tmux-0/default\\t900\\t0\\t1\\t1788092500\\t0\\t%1\\tbash\\tscratch\\t/home/me',
|
||||
'CMFH /tmp/tmux-0/stopped',
|
||||
'CMFH /tmp/tmux-0/codeman-other',
|
||||
'CMFQ',
|
||||
].join('\n');
|
||||
const notes: string[] = [];
|
||||
const rows = candidatesFrom(stdout, 'local', 'local', {}, 'local', notes);
|
||||
expect(rows.map((r) => r.sessionName)).toEqual(['scratch']);
|
||||
// Another Codeman's socket is not a session this list would ever show.
|
||||
expect(notes).toEqual(['local: 1 tmux server(s) did not answer, sessions on them are not listed']);
|
||||
// The adopt route reads a note with `skipped` in it as a refusal rather than as a
|
||||
// location it could not reach, and this one is the second kind.
|
||||
expect(notes[0]).not.toMatch(/skipped/);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(spawnSync('tmux', ['-V']).status !== 0)('a real stopped tmux server', () => {
|
||||
it('costs a scan the ping and nothing more, though it holds stdout past its client', async () => {
|
||||
// The stand-in above cannot show this. Only a real server takes a dup of the client's
|
||||
// stdout, and that is what kept a scan waiting after the client was dead.
|
||||
const root = mkdtempSync('/tmp/cmfr-');
|
||||
const sockets = join(root, `tmux-${process.getuid?.() ?? 0}`);
|
||||
mkdirSync(sockets);
|
||||
const stopped = join(sockets, 'stopped');
|
||||
const env = { ...process.env, TMUX: '' };
|
||||
const tmux = (...args: string[]) => spawnSync('tmux', ['-S', stopped, ...args], { encoding: 'utf-8', env });
|
||||
let serverPid = 0;
|
||||
try {
|
||||
expect(tmux('-f', '/dev/null', 'new-session', '-d', '-s', 'held', 'sleep 60').status).toBe(0);
|
||||
serverPid = Number(tmux('display-message', '-p', '#{pid}').stdout);
|
||||
expect(serverPid).toBeGreaterThan(0);
|
||||
process.kill(serverPid, 'SIGSTOP');
|
||||
|
||||
const startedAt = Date.now();
|
||||
const run = await runForeignProbe(['env', `TMUX_TMPDIR=${root}`, 'sh', '-c', buildForeignProbeScript()]);
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(6000);
|
||||
expect(run.ok).toBe(true);
|
||||
const out = parseForeignProbeOutput(run.stdout);
|
||||
expect(out.hungSockets).toEqual([stopped]);
|
||||
expect(out.panes).toEqual([]);
|
||||
} finally {
|
||||
if (serverPid > 0) process.kill(serverPid, 'SIGCONT');
|
||||
tmux('kill-server');
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
describe('runForeignProbe', () => {
|
||||
const alive = (pid: number) =>
|
||||
spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf-8' }).stdout.trim();
|
||||
|
||||
it('reaches the whole process group at the deadline and keeps what was read', async () => {
|
||||
// A timeout that signals only `sh` left its tmux child stuck on the hung server, one
|
||||
// more per scan, for as long as the home screen stayed open.
|
||||
const run = await runForeignProbe(['sh', '-c', 'echo first; sleep 60 & echo $!; wait'], { timeoutMs: 400 });
|
||||
expect(run.ok).toBe(false);
|
||||
const [first, pid] = run.stdout.trim().split('\n');
|
||||
expect(first).toBe('first');
|
||||
await vi.waitFor(() => expect(alive(Number(pid))).toMatch(/^(Z.*)?$/), { timeout: 2000 });
|
||||
});
|
||||
|
||||
it('answers at the deadline even when something outside the group holds the pipe', async () => {
|
||||
// What a stopped tmux server does. `setsid` is the stand-in for it: out of the group,
|
||||
// so the kill misses it, and it keeps the write end of stdout open.
|
||||
if (spawnSync('sh', ['-c', 'command -v setsid']).status !== 0) return;
|
||||
const startedAt = Date.now();
|
||||
const run = await runForeignProbe(['sh', '-c', 'echo first; setsid sleep 3 & exit 0'], { timeoutMs: 300 });
|
||||
expect(Date.now() - startedAt).toBeLessThan(2000);
|
||||
expect(run).toEqual({ stdout: 'first\n', ok: false });
|
||||
});
|
||||
|
||||
it('stops a probe that floods instead of holding it in memory', async () => {
|
||||
const run = await runForeignProbe(['sh', '-c', 'while :; do echo aaaaaaaa; done'], {
|
||||
maxBytes: 4096,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(run.ok).toBe(false);
|
||||
expect(run.stdout.length).toBeLessThanOrEqual(4096);
|
||||
});
|
||||
|
||||
it('keeps the output of a command that failed', async () => {
|
||||
expect(await runForeignProbe(['sh', '-c', 'echo partial; exit 3'])).toEqual({ stdout: 'partial\n', ok: false });
|
||||
});
|
||||
|
||||
it('answers rather than throws when the command cannot start', async () => {
|
||||
expect(await runForeignProbe(['/nonexistent/probe'])).toEqual({ stdout: '', ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
*
|
||||
* Port: none (app.inject()).
|
||||
*/
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
@@ -102,6 +103,43 @@ describe('adopted sessions refuse every auto-op that drives the pane', () => {
|
||||
expect(res.json().error).toMatch(/adopted/i);
|
||||
await h.app.close();
|
||||
});
|
||||
|
||||
// ⚠️ Neither of these is a mock-only concern: an adopted session's `workingDir` is the
|
||||
// neutral wrapper folder `/tmp`, so a live one wrote `/tmp/@ralph_prompt.md` on the host
|
||||
// and answered success, a predictable name in a folder anyone can write to.
|
||||
describe.each([
|
||||
['fix-plan/write', {}],
|
||||
['fix-plan/read', {}],
|
||||
['ralph-prompt/write', { content: 'prompt' }],
|
||||
])('POST %s', (route, payload) => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'adopted-ralph-'));
|
||||
writeFileSync(join(dir, '@fix_plan.md'), '- [ ] task\n');
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
const post = async (isAdopted: boolean) => {
|
||||
const h = await createRouteTestHarness(registerRalphRoutes, { sessionId: SID });
|
||||
const ralphTracker = { todos: [], generateFixPlanMarkdown: () => '', importFixPlanMarkdown: () => 0 };
|
||||
Object.assign(h.ctx._session as object, { isAdopted, workingDir: dir, ralphTracker });
|
||||
const res = await h.app.inject({ method: 'POST', url: `/api/sessions/${SID}/${route}`, payload });
|
||||
await h.app.close();
|
||||
return res.json();
|
||||
};
|
||||
|
||||
it('refuses an adopted session and touches nothing', async () => {
|
||||
const before = readdirSync(dir);
|
||||
const body = await post(true);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toMatch(/adopted/i);
|
||||
expect(readdirSync(dir)).toEqual(before);
|
||||
});
|
||||
|
||||
it('still answers for a session Codeman started', async () => {
|
||||
expect((await post(false)).success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/mux/foreign', () => {
|
||||
|
||||
@@ -184,6 +184,34 @@ describe('closing the active session', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('closing an adopted tab', () => {
|
||||
it('re-reads the outside-sessions list once the wrapper is gone', async () => {
|
||||
// The home screen loads that list as soon as the tab goes, which is BEFORE the DELETE
|
||||
// lands, so the row kept saying "Go to tab" with no tab behind it until the next poll.
|
||||
const app = makeApp(A, [A]);
|
||||
app.sessions.set(A, { id: A, adopt: { targetSession: 'work' } });
|
||||
const order: string[] = [];
|
||||
app._apiDelete = vi.fn(async () => {
|
||||
order.push('delete');
|
||||
return { ok: true, status: 200 };
|
||||
});
|
||||
app.loadForeignSessions = vi.fn(async () => void order.push('reload'));
|
||||
|
||||
await app.closeSession(A);
|
||||
|
||||
expect(order).toEqual(['delete', 'reload']);
|
||||
});
|
||||
|
||||
it('leaves the list alone for a session of our own', async () => {
|
||||
const app = makeApp(A, [A]);
|
||||
app.loadForeignSessions = vi.fn();
|
||||
|
||||
await app.closeSession(A);
|
||||
|
||||
expect(app.loadForeignSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('closing is optimistic', () => {
|
||||
it('drops the tab and selects the next one BEFORE the server answers', async () => {
|
||||
const app = makeApp(A);
|
||||
|
||||
Reference in new issue
Block a user