mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
fix: keep foreign probe parsing linear in what it reads (release review)
The parser and classifier run synchronously on the event loop over text a pane, a container or an ssh host wrote, and one process could stall the server for every session: 4 s for a 128 KiB argv of `/pi/` tokens, about a minute for 2 MiB of socket rows. The `cli.js` launcher rule leaves the signature regex for a per-word scan, socket rows dedupe through a Set, the process row regex takes the `s` flag (a bare CR made `$` fail and `\s+` give its spaces back, quadratic in a row), argv and pane command are cut at FOREIGN_MAX_ARGV_CHARS, and one matcher per scan answers each distinct argv once, since every pane of a hostile host can claim the same process tree. Measured on a private tmux server with the same 131 KiB argv: the largest event-loop gap went from 3991 ms to 45 ms. The worst scan the caps allow takes about 80 ms. Socket rows stay uncapped on purpose (see the docs). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2c084966f1
commit
800b3f4658
5 files changed
+187
-24
No files matched your search
@@ -105,6 +105,8 @@ Input arriving during a wake is buffered: a chunk over 4 KB is dropped whole, ne
|
||||
|
||||
**A server that stops answering.** A tmux client never returns from a stopped server, and it hands dups of its stdout to the server, so that server holds our pipe open after the client is dead (measured: SIGTERM ended the client and no EOF came). ⚠️ So a watchdog around `list-panes` cannot work, and the same hold would keep `docker exec` and `ssh` waiting. `buildForeignProbeScript()` pings each socket with `has-session` at `/dev/null` under `FOREIGN_SOCKET_TIMEOUT_SECONDS`, in the script because no signal of ours reaches a client inside a container or across ssh, and only a server that answered gets the real stdout. One that did not prints `CMFH` and costs its own rows and a note. The watchdog reports through its own exit status, since a client ended by SIGTERM exits 0. `runForeignProbe()` runs all three transports in their own process group, signals the group at the deadline, destroys its end of the pipe by hand (or one handle leaks per scan) and keeps the rows it read. ⚠️ Sockets have no count cap: in glob order `codeman*` sorts before `default`, and a box with many instances would never reach the sessions this feature is for (212 sockets measured here). The sweep is bounded by time.
|
||||
|
||||
**Text from someone else's process.** The parser and the classifier run synchronously on the event loop over what a pane, a container or an ssh host printed, and the row caps do not help when one row is the whole 2 MiB. ⚠️ So every step stays linear in that text: no regex tail over an argv (`launcherPrefixes()` is a scan), `.*` under the `s` flag so `$` cannot fail and hand `\s+` its spaces back, a `Set` for the socket rows, `FOREIGN_MAX_ARGV_CHARS` on what is kept, and one `createForeignSignatureMatcher()` per scan, since every pane of a hostile host can claim the same process tree and repeat the same rows.
|
||||
|
||||
**One wrapper per target.** `foreignTargetKey()` is the ONE key, read by the adopt route and `adoptedBy`, built from the location, the host identity, the socket and the name: a socket and a name alone collide across containers and hosts. Both read live `Session`s, since the mux record only exists after the first attach.
|
||||
|
||||
**No workspace.** ⚠️ An adopted session keeps its connection on `adopt.remote`/`adopt.docker`, so `session.remote` and `session.docker` are undefined, and its `workingDir` is the neutral `/tmp`. Code that branches on those two reads an in-container or over-ssh adoption as a local session working in `/tmp`. So anything that treats `workingDir` as the session's workspace must also test `session.isAdopted` (`adopt` on a pure view): uploads, the upload GC, git status, git fetch and its scheduler, the image watcher, Read My Mind's git signals, the Discord folder, `last-response`, guarded input and Ralph's `@fix_plan.md` and `@ralph_prompt.md` routes all do. The file surfaces (Files panel, File Viewer and its edit mode) still treat `/tmp` as the workspace, which is harmless: it is world-writable anyway, and adoption is admin-only in multi-user mode. A wrapper reports `statusLineExporter: 'remote'`, which is also what keeps keep-warm off it.
|
||||
|
||||
@@ -46,3 +46,11 @@ export const FOREIGN_MAX_PROCS = 4000;
|
||||
|
||||
/** Max bytes of probe stdout kept. A runaway `ps` must not become a heap problem. */
|
||||
export const FOREIGN_PROBE_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Longest argv or pane command kept from one row. The caps above bound how many rows a
|
||||
* probe carries, this bounds what one costs: a row is text from someone else's process,
|
||||
* and `ps` prints a 128 KiB argv in full. A launcher that needed more than this to be
|
||||
* recognised reads as `shell`, which only changes the label on its row.
|
||||
*/
|
||||
export const FOREIGN_MAX_ARGV_CHARS = 4096;
|
||||
@@ -42,6 +42,7 @@ import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import {
|
||||
buildForeignProbeScript,
|
||||
classifyForeignPaneMode,
|
||||
createForeignSignatureMatcher,
|
||||
foreignRemoteShellCommand,
|
||||
foreignSessionId,
|
||||
isAdoptableSessionName,
|
||||
@@ -158,6 +159,7 @@ export function candidatesFrom(
|
||||
notes: string[]
|
||||
): ForeignTmuxSession[] {
|
||||
const probe = parseForeignProbeOutput(stdout);
|
||||
const match = createForeignSignatureMatcher();
|
||||
const ownSocket = resolveTmuxSocketName();
|
||||
const out: ForeignTmuxSession[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -183,7 +185,7 @@ export function candidatesFrom(
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const classified = classifyForeignPaneMode(pane, probe);
|
||||
const classified = classifyForeignPaneMode(pane, probe, match);
|
||||
out.push({
|
||||
id: foreignSessionId(location, hostKey, pane.socketPath, pane.sessionName),
|
||||
location,
|
||||
|
||||
+81
-23
@@ -87,7 +87,12 @@
|
||||
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_SOCKET_TIMEOUT_SECONDS } from './config/foreign-tmux.js';
|
||||
import {
|
||||
FOREIGN_MAX_ARGV_CHARS,
|
||||
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';
|
||||
|
||||
@@ -200,14 +205,20 @@ export interface ForeignProbeOutput {
|
||||
hungSockets: string[];
|
||||
}
|
||||
|
||||
/** Parse one probe's stdout. Never throws: a malformed row is skipped, not fatal. */
|
||||
/**
|
||||
* Parse one probe's stdout. Never throws: a malformed row is skipped, not fatal.
|
||||
*
|
||||
* ⚠️ Runs synchronously on the server's event loop over text a process in a pane, a
|
||||
* container or an ssh host wrote, so nothing here may cost more than linear time in it:
|
||||
* the row caps do not help when one row is the whole 2 MiB.
|
||||
*/
|
||||
export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
|
||||
const panes: ForeignPaneProbe[] = [];
|
||||
const sockets: string[] = [];
|
||||
const sockets = new Set<string>();
|
||||
const hungSockets: string[] = [];
|
||||
const byParent = new Map<number, number[]>();
|
||||
const argvByPid = new Map<number, string>();
|
||||
if (!stdout) return { panes, byParent, argvByPid, sockets, hungSockets };
|
||||
if (!stdout) return { panes, byParent, argvByPid, sockets: [], hungSockets };
|
||||
|
||||
let inProcs = false;
|
||||
let procCount = 0;
|
||||
@@ -224,13 +235,15 @@ export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
|
||||
if (inProcs) {
|
||||
if (procCount >= FOREIGN_MAX_PROCS) continue;
|
||||
// `<pid> <ppid> <argv...>` — leading spaces are how `ps -o pid=` pads.
|
||||
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
||||
// ⚠️ `s`, so `.*$` cannot fail: without it a bare CR in an argv stopped the `.`, and
|
||||
// `\s+` handed its spaces back one at a time, quadratic in a row this long.
|
||||
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/s.exec(line);
|
||||
if (!m) continue;
|
||||
const pid = Number(m[1]);
|
||||
const ppid = Number(m[2]);
|
||||
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(ppid)) continue;
|
||||
procCount += 1;
|
||||
argvByPid.set(pid, m[3] ?? '');
|
||||
argvByPid.set(pid, (m[3] ?? '').slice(0, FOREIGN_MAX_ARGV_CHARS));
|
||||
const siblings = byParent.get(ppid);
|
||||
if (siblings) siblings.push(pid);
|
||||
else byParent.set(ppid, [pid]);
|
||||
@@ -239,7 +252,7 @@ export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
|
||||
|
||||
if (line.startsWith(`${SOCKET_MARKER} `)) {
|
||||
const sock = line.slice(SOCKET_MARKER.length + 1);
|
||||
if (sock && !sockets.includes(sock)) sockets.push(sock);
|
||||
if (sock) sockets.add(sock);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -272,7 +285,7 @@ export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
|
||||
windowIndex: Number.isSafeInteger(windowIndex) ? windowIndex : 0,
|
||||
paneId: fields[7] ?? '',
|
||||
panePid,
|
||||
paneCurrentCommand: fields[8] ?? '',
|
||||
paneCurrentCommand: (fields[8] ?? '').slice(0, FOREIGN_MAX_ARGV_CHARS),
|
||||
paneCurrentPath,
|
||||
sessionAttached: fields[6] === '1',
|
||||
sessionCreated: Number.isSafeInteger(created) && created > 0 ? created : 0,
|
||||
@@ -280,7 +293,7 @@ export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
|
||||
});
|
||||
}
|
||||
|
||||
return { panes, byParent, argvByPid, sockets, hungSockets };
|
||||
return { panes, byParent, argvByPid, sockets: [...sockets], hungSockets };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -309,10 +322,14 @@ export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
|
||||
* while the schema's SHELL_TOKEN already refuses the dangerous shapes, building
|
||||
* a pattern out of an unescaped foreign string is how a config value turns into
|
||||
* a regex. Escaping means the worst a bad name can do is match nothing.
|
||||
*
|
||||
* ⚠️ Keep these free of unbounded tails, since each one runs over someone else's argv on
|
||||
* the event loop. The node launcher shape needs one, so it lives in `launcherPrefixes()`
|
||||
* and `launcherDir` only has to name the directory.
|
||||
*/
|
||||
export function foreignCliSignatures(): ReadonlyArray<{ mode: SessionMode; test: RegExp }> {
|
||||
export function foreignCliSignatures(): ReadonlyArray<{ mode: SessionMode; test: RegExp; launcherDir: RegExp }> {
|
||||
const seen = new Set<string>();
|
||||
const out: { mode: SessionMode; test: RegExp }[] = [];
|
||||
const out: { mode: SessionMode; test: RegExp; launcherDir: RegExp }[] = [];
|
||||
// `listClis()`, not `enabledClis()`: classification is a description of what is
|
||||
// ALREADY running in someone else's pane, not a decision to launch anything.
|
||||
// A disabled CLI still deserves its real name on the row.
|
||||
@@ -338,11 +355,10 @@ export function foreignCliSignatures(): ReadonlyArray<{ mode: SessionMode; test:
|
||||
const n = escapeRegExp(name);
|
||||
out.push({
|
||||
mode,
|
||||
test: new RegExp(
|
||||
// the command itself · a `bin/` path · a node launcher under the CLI's
|
||||
// own dot-directory (`~/.claude/local/.../cli.js`)
|
||||
`(^|[/\\\\])${n}(\\s|$)|[/\\\\]bin[/\\\\]${n}\\b|[/\\\\]\\.?${n}[/\\\\][^\\s]*cli\\.js`
|
||||
),
|
||||
// the command itself · a `bin/` path
|
||||
test: new RegExp(`(^|[/\\\\])${n}(\\s|$)|[/\\\\]bin[/\\\\]${n}\\b`),
|
||||
// a node launcher under the CLI's own dot-directory (`~/.claude/local/.../cli.js`)
|
||||
launcherDir: new RegExp(`[/\\\\]\\.?${n}[/\\\\]`),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -388,11 +404,12 @@ const COMMAND_DISPLAY_MAX = 160;
|
||||
*/
|
||||
export function classifyForeignPaneMode(
|
||||
pane: Pick<ForeignPaneProbe, 'panePid' | 'paneCurrentCommand'>,
|
||||
probe: Pick<ForeignProbeOutput, 'byParent' | 'argvByPid'>
|
||||
probe: Pick<ForeignProbeOutput, 'byParent' | 'argvByPid'>,
|
||||
match: ForeignSignatureMatcher = createForeignSignatureMatcher()
|
||||
): ClassifiedPane {
|
||||
const own = probe.argvByPid.get(pane.panePid) ?? pane.paneCurrentCommand;
|
||||
|
||||
const direct = matchSignature(own);
|
||||
const direct = match(own);
|
||||
if (direct) return { mode: direct, command: truncate(own) };
|
||||
|
||||
// ⚠️ Report truncation. A walk that stopped at a cap returns "nothing matched",
|
||||
@@ -406,7 +423,7 @@ export function classifyForeignPaneMode(
|
||||
for (const pid of descendants) {
|
||||
const argv = probe.argvByPid.get(pid);
|
||||
if (!argv) continue;
|
||||
const hit = matchSignature(argv);
|
||||
const hit = match(argv);
|
||||
if (hit) return { mode: hit, command: truncate(argv) };
|
||||
}
|
||||
|
||||
@@ -414,7 +431,7 @@ export function classifyForeignPaneMode(
|
||||
// is the pane's FOREGROUND process, whereas `pane_pid` is the shell hosting it,
|
||||
// so this catches an agent whose process tree was unreadable (no `ps` on the
|
||||
// host) or deeper than the walk's cap.
|
||||
const fromTmux = matchSignature(pane.paneCurrentCommand);
|
||||
const fromTmux = match(pane.paneCurrentCommand);
|
||||
if (fromTmux) return { mode: fromTmux, command: truncate(pane.paneCurrentCommand) };
|
||||
|
||||
// Nothing recognised. `shell` is the honest answer AND the primary case this
|
||||
@@ -423,16 +440,57 @@ export function classifyForeignPaneMode(
|
||||
return { mode: 'shell', command: truncate(own || pane.paneCurrentCommand) };
|
||||
}
|
||||
|
||||
function matchSignature(argv: string): SessionMode | null {
|
||||
export type ForeignSignatureMatcher = (argv: string) => SessionMode | null;
|
||||
|
||||
/**
|
||||
* One scan's answer to "which CLI is this argv?".
|
||||
*
|
||||
* ⚠️ Scoped to a scan, because this is the cost no row cap bounds. A pane's walk reads up
|
||||
* to PROC_WALK_MAX_NODES rows and every pane of a hostile host can claim the same tree, so
|
||||
* the walks repeat one set of rows FOREIGN_MAX_PANES times. Building the regexes once and
|
||||
* asking about each distinct argv once ties the work to the bytes the probe really carried.
|
||||
*/
|
||||
export function createForeignSignatureMatcher(): ForeignSignatureMatcher {
|
||||
const signatures = foreignCliSignatures();
|
||||
const answers = new Map<string, SessionMode | null>();
|
||||
return (argv) => {
|
||||
let answer = answers.get(argv);
|
||||
if (answer === undefined) {
|
||||
answer = matchSignature(argv, signatures);
|
||||
answers.set(argv, answer);
|
||||
}
|
||||
return answer;
|
||||
};
|
||||
}
|
||||
|
||||
function matchSignature(argv: string, signatures: ReturnType<typeof foreignCliSignatures>): SessionMode | null {
|
||||
if (!argv) return null;
|
||||
const base = basenameOf(argv);
|
||||
if (SHELL_COMMANDS.has(base)) return null;
|
||||
for (const sig of foreignCliSignatures()) {
|
||||
if (sig.test.test(argv)) return sig.mode;
|
||||
const launchers = launcherPrefixes(argv);
|
||||
for (const sig of signatures) {
|
||||
if (sig.test.test(argv) || launchers.some((prefix) => sig.launcherDir.test(prefix))) return sig.mode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What comes before the last `cli.js` of each word, which is where a CLI's own directory
|
||||
* has to be for that word to be its node launcher.
|
||||
*
|
||||
* ⚠️ A scan rather than a `[^\s]*cli\.js` tail on the signature. That tail was retried from
|
||||
* every `/pi/` in an argv, which is quadratic in someone else's text: 4.6 s of event loop
|
||||
* for a 128 KiB argv. A bounded tail only shrinks the constant, and a scan pays it per row.
|
||||
*/
|
||||
function launcherPrefixes(argv: string): string[] {
|
||||
const prefixes: string[] = [];
|
||||
for (const word of argv.split(/\s+/)) {
|
||||
const end = word.lastIndexOf('cli.js');
|
||||
if (end > 0) prefixes.push(word.slice(0, end));
|
||||
}
|
||||
return prefixes;
|
||||
}
|
||||
|
||||
function basenameOf(argv: string): string {
|
||||
const first = argv.trim().split(/\s+/)[0] ?? '';
|
||||
const slash = first.lastIndexOf('/');
|
||||
|
||||
@@ -38,6 +38,12 @@ import { bashCArg, 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';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
import {
|
||||
FOREIGN_MAX_ARGV_CHARS,
|
||||
FOREIGN_MAX_PANES,
|
||||
FOREIGN_MAX_PROCS,
|
||||
FOREIGN_PROBE_MAX_BYTES,
|
||||
} from '../src/config/foreign-tmux.js';
|
||||
import { hooksAvailableForMode, sessionHookOptions } from '../src/web/session-wait-registry.js';
|
||||
|
||||
// A probe transcript in exactly the shape a real run produces. The pane rows use
|
||||
@@ -913,3 +919,90 @@ describe('hooksAvailableForMode for an adopted session', () => {
|
||||
expect(sessionHookOptions({}).adopted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Parsing and classifying runs synchronously on the server's event loop, over text that
|
||||
* a process in someone's pane, a container or an ssh host wrote. Each budget below is
|
||||
* 100 times what the fixed code needs and a small fraction of what the unfixed code took.
|
||||
*/
|
||||
describe('a probe someone else wrote cannot stall the server', () => {
|
||||
const SEC = 1000;
|
||||
const elapsed = (work: () => void) => {
|
||||
const startedAt = performance.now();
|
||||
work();
|
||||
return performance.now() - startedAt;
|
||||
};
|
||||
|
||||
it('keeps a bounded piece of an endless argv and of an endless pane command', () => {
|
||||
const long = 'x'.repeat(FOREIGN_MAX_ARGV_CHARS * 4);
|
||||
const pane = `CMFP\\t/tmp/tmux-0/s\\t7\\t0\\t1\\t0\\t0\\t%0\\t${long}\\twork\\t/srv`;
|
||||
const out = parseForeignProbeOutput(`${pane}\nCMFQ\n 7 1 ${long}\n`);
|
||||
expect(out.argvByPid.get(7)).toHaveLength(FOREIGN_MAX_ARGV_CHARS);
|
||||
expect(out.panes[0]?.paneCurrentCommand).toHaveLength(FOREIGN_MAX_ARGV_CHARS);
|
||||
});
|
||||
|
||||
it('classifies an argv of repeated `/pi/` tokens in linear time', () => {
|
||||
// Unfixed, the `cli.js` tail was retried from every token: 4.6 s for this input.
|
||||
const argv = '/pi/'.repeat(32 * 1024);
|
||||
const probe = { byParent: new Map(), argvByPid: new Map([[7, argv]]) };
|
||||
expect(elapsed(() => classifyForeignPaneMode({ panePid: 7, paneCurrentCommand: 'x' }, probe))).toBeLessThan(SEC);
|
||||
});
|
||||
|
||||
it('still needs the `cli.js` to come after the CLI directory, inside one word', () => {
|
||||
const empty = new Map<number, number[]>();
|
||||
const classify = (argv: string) =>
|
||||
classifyForeignPaneMode(
|
||||
{ panePid: 7, paneCurrentCommand: 'node' },
|
||||
{ byParent: empty, argvByPid: new Map([[7, argv]]) }
|
||||
).mode;
|
||||
expect(classify('node /opt/.pi/lib/cli.js')).toBe('pi');
|
||||
expect(classify('node /opt/pi/cli.js --flag')).toBe('pi');
|
||||
expect(classify('node /opt/.pi/lib/cli.json')).toBe('pi');
|
||||
expect(classify('node cli.js/.pi/lib')).toBe('shell');
|
||||
expect(classify('node /opt/.pi/lib tool/cli.js')).toBe('shell');
|
||||
});
|
||||
|
||||
it('dedupes socket rows in linear time', () => {
|
||||
// Unfixed: 7 s for this many, and 65 s for the 2 MiB a probe may carry.
|
||||
const lines = Array.from({ length: 20_000 }, (_, i) => `CMFS /tmp/tmux-0/s${i}`);
|
||||
let sockets: string[] = [];
|
||||
expect(elapsed(() => (sockets = parseForeignProbeOutput(lines.join('\n')).sockets))).toBeLessThan(SEC);
|
||||
expect(sockets).toHaveLength(20_000);
|
||||
expect(parseForeignProbeOutput('CMFS /a\nCMFS /a\n').sockets).toEqual(['/a']);
|
||||
});
|
||||
|
||||
it('parses a process row whose argv holds a line terminator in linear time', () => {
|
||||
// `.` stops at a bare CR, so `$` used to fail and `\s+` gave its spaces back one at a
|
||||
// time: 4 s for this row, and about an hour for a row at the 2 MiB cap. `ps` escapes
|
||||
// control characters here, a hostile container or ssh host has no such manners.
|
||||
let out = parseForeignProbeOutput('');
|
||||
const row = `CMFQ\n 7 1 ${' '.repeat(64 * 1024)}x\ry\n`;
|
||||
expect(elapsed(() => (out = parseForeignProbeOutput(row)))).toBeLessThan(SEC);
|
||||
expect(out.argvByPid.has(7)).toBe(true);
|
||||
});
|
||||
|
||||
it('survives the worst scan the caps allow', () => {
|
||||
// Every pane may claim one process tree, so a walk per pane repeats the same rows:
|
||||
// FOREIGN_MAX_PANES walks over the longest argv rows the byte cap leaves room for,
|
||||
// none of them matching, so no walk ends early. Only a hostile host can say that.
|
||||
const panes = Array.from(
|
||||
{ length: FOREIGN_MAX_PANES },
|
||||
(_, i) => `CMFP\\t/tmp/tmux-0/s\\t7\\t0\\t1\\t0\\t0\\t%${i}\\tnode\\tw${i}\\t/srv`
|
||||
);
|
||||
const argv = `cli.js${'/pi/'.repeat(Math.floor((FOREIGN_MAX_ARGV_CHARS - 16) / 4))}`;
|
||||
const rows: string[] = [];
|
||||
let bytes = panes.join('\n').length;
|
||||
for (let pid = 1000; rows.length < FOREIGN_MAX_PROCS; pid += 1) {
|
||||
const row = `${pid} 7 ${argv}${pid}`;
|
||||
if (bytes + row.length + 1 > FOREIGN_PROBE_MAX_BYTES) break;
|
||||
bytes += row.length + 1;
|
||||
rows.push(row);
|
||||
}
|
||||
const stdout = `${panes.join('\n')}\nCMFQ\n${rows.join('\n')}\n`;
|
||||
let found: { mode: string }[] = [];
|
||||
const took = elapsed(() => (found = candidatesFrom(stdout, 'remote', 'h', {}, 'remote h', [])));
|
||||
expect(found).toHaveLength(FOREIGN_MAX_PANES);
|
||||
expect(found.every((session) => session.mode === 'shell')).toBe(true);
|
||||
expect(took).toBeLessThan(SEC);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user