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:
Codeman maintainer committed 2026-11-06 07:10:49 +01:00
1 parent 2c084966f1
commit 800b3f4658
5 files changed
+187 -24

No files matched your search

+93
View File
@@ -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);
});
});