Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { execSync, execFileSync, spawnSync, spawn } from 'node:child_process';
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync, symlinkSync, appendFileSync, statSync, unlinkSync, rmSync, realpathSync, chmodSync } from 'node:fs';
import { underReadIsolation, sendCredFilePath } from './adapters/cli/read-isolation.js';
import { atomicWriteFileSync } from './utils/atomic-write.js';
import { writeAndFlush } from './cli/stdout-flush.js';
import { join, dirname, basename, resolve } from 'node:path';
import { homedir, userInfo } from 'node:os';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -7111,8 +7112,19 @@ async function relaySend(
try { unlinkSync(cfile); } catch { /* */ }
if (preparedContentOutfile) { try { unlinkSync(preparedContentOutfile); } catch { /* */ } }
if (cardOutfile) { try { unlinkSync(cardOutfile); } catch { /* */ } }
if (res.stdout) process.stdout.write(res.stdout);
if (res.stderr) process.stderr.write(res.stderr);
// The host child normally returns a short success JSON, but either
// stream can contain an upstream error body. This relay exits
// explicitly, so wait for both writes or a large response is silently
// truncated at the sandbox boundary.
try {
await Promise.all([
res.stdout ? writeAndFlush(process.stdout, res.stdout) : Promise.resolve(),
res.stderr ? writeAndFlush(process.stderr, res.stderr) : Promise.resolve(),
]);
} catch (writeError) {
console.error(`relay: 无法完整输出 host 响应:${writeError instanceof Error ? writeError.message : String(writeError)}`);
process.exit(1);
}
process.exit(res.code ?? 0);
} catch { /* partial write — retry next tick */ }
}
Expand Down Expand Up @@ -10737,6 +10749,7 @@ async function cmdAsk(sub: string, rest: string[]): Promise<void> {
// result.kind==='answered' 时用 toLegacySelected 取回旧的 string(单问单选)
const selected = toLegacySelected(result);

let stdoutPayload: string | undefined;
if (useJson) {
const out: AskJsonOutput = {
// `selected` 是「单问单选」的向后兼容值(= toLegacySelected 的形状判据:
Expand All @@ -10749,7 +10762,7 @@ async function cmdAsk(sub: string, rest: string[]): Promise<void> {
comment: result.kind === 'answered' ? result.comment : null,
timedOut: result.kind === 'timedOut',
};
process.stdout.write(JSON.stringify(out) + '\n');
stdoutPayload = JSON.stringify(out) + '\n';
} else if (result.kind === 'answered') {
// 非 JSON 模式:单选输出 key,多选输出逗号分隔的 keys。
//
Expand All @@ -10766,7 +10779,19 @@ async function cmdAsk(sub: string, rest: string[]): Promise<void> {
// N 项→逗号分隔,全程 exit 0。文字作答时 answers[0] 为空数组同样落空行(上面已在
// stderr 提示改读 --json 的 comment)。
const value = multiSelect ? (result.answers[0]?.join(',') ?? '') : (selected ?? '');
process.stdout.write(value + '\n');
stdoutPayload = value + '\n';
}

// `ask --json` includes the user's free-form comment, whose size is not
// bounded by this CLI. stdout is asynchronous for pipes; an immediate
// process.exit() can otherwise lose everything after the pipe buffer.
if (stdoutPayload !== undefined) {
try {
await writeAndFlush(process.stdout, stdoutPayload);
} catch (writeError) {
console.error(`botmux ask: stdout 输出失败:${writeError instanceof Error ? writeError.message : String(writeError)}`);
process.exit(1);
}
}

switch (result.kind) {
Expand Down
12 changes: 10 additions & 2 deletions src/cli/pm2-readonly-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
revalidateLinuxPm2GodProcess,
type LinuxPm2GodProcess,
} from '../core/pm2-lifecycle-owner.js';
import { writeAndFlush } from './stdout-flush.js';

const require = createRequire(import.meta.url);
const pm2 = require('pm2') as any;
Expand Down Expand Up @@ -81,8 +82,15 @@ pm2.Client.pingDaemon((alive: boolean) => {
if (mode === 'jlist') {
pm2.list((error: Error | null | undefined, list: unknown[]) => {
if (error) fail(`PM2 read-only jlist failed: ${error.message}`);
process.stdout.write(JSON.stringify(Array.isArray(list) ? list : []));
pm2.disconnect(() => process.exit(0));
// A PM2 registry can exceed the pipe's high-water mark. Calling
// process.exit() immediately after write() truncates its tail, making
// the parent reject a valid registry as malformed. Wait for stdout's
// completion callback before disconnecting and exiting.
void writeAndFlush(process.stdout, JSON.stringify(Array.isArray(list) ? list : []))
.then(
() => pm2.disconnect(() => process.exit(0)),
writeError => fail(`PM2 read-only jlist stdout write failed: ${writeError instanceof Error ? writeError.message : String(writeError)}`),
);
});
return;
}
Expand Down
16 changes: 16 additions & 0 deletions src/cli/stdout-flush.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** A minimal writable surface whose completion callback means the chunk has
* reached the underlying stream. `write()` returning false is only a
* backpressure signal; callers that must not exit early must await callback. */
export interface CompletionWritable {
write(chunk: string, callback: (error?: Error | null) => void): boolean;
}

/** Write one complete payload before allowing a child process to exit. */
export function writeAndFlush(stream: CompletionWritable, chunk: string): Promise<void> {
return new Promise((resolve, reject) => {
stream.write(chunk, error => {
if (error) reject(error);
else resolve();
});
});
}
46 changes: 46 additions & 0 deletions test/ask-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,4 +193,50 @@ describe('botmux ask — CLI boundary', () => {
});
}
});

it('--json 完整输出超出 pipe 容量的文字作答', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'botmux-ask-cli-'));
tempDirs.push(dataDir);
const comment = 'x'.repeat(500_000);

const server = createServer((req, res) => {
req.resume();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
kind: 'answered',
answers: [[]],
by: 'ou_test',
comment,
timedOut: false,
}));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));

try {
const port = (server.address() as AddressInfo).port;
const registryDir = join(dataDir, 'dashboard-daemons');
mkdirSync(registryDir, { recursive: true });
writeFileSync(
join(registryDir, 'cli_test.json'),
JSON.stringify({ larkAppId: 'cli_test', ipcPort: port, lastHeartbeat: Date.now() }),
);

const result = await runAsk(dataDir, [
'ask', 'buttons', '--json', '--options', 'yes,no', '请作答',
]);
expect(result.status).toBe(0);
expect(Buffer.byteLength(result.stdout)).toBeGreaterThan(400_000);
expect(JSON.parse(result.stdout)).toMatchObject({
selected: null,
answers: [[]],
by: 'ou_test',
comment,
timedOut: false,
});
} finally {
await new Promise<void>((resolve, reject) => {
server.close((err) => err ? reject(err) : resolve());
});
}
}, 30_000);
});
58 changes: 58 additions & 0 deletions test/pm2-readonly-jlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import { captureReadonlyPm2Jlist } from '../src/cli/pm2-readonly.js';

const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const PM2_PATH = join(PKG_ROOT, 'node_modules', 'pm2', 'bin', 'pm2');
const homes: string[] = [];

function tempHome(): string {
const home = mkdtempSync(join(tmpdir(), 'botmux-pm2-readonly-'));
homes.push(home);
return home;
}

afterEach(() => {
for (const home of homes.splice(0)) {
const pm2Home = join(home, '.botmux', 'pm2');
spawnSync(process.execPath, [PM2_PATH, 'kill'], {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route PM2 subprocesses through the test runner helper

The new regression test invokes PM2 directly with spawnSync(process.execPath, ...) here and again for status and start, bypassing the repository’s mandatory runtime-aware subprocess abstraction. This leaves the test and its teardown outside the supported Node/Bun launch contract, so they can fail or exercise different behavior when the suite runs under Bun; use spawnSyncTsScript from test/helpers/ts-runner.ts for all three invocations.

AGENTS.md reference: AGENTS.md:L28-L28

Useful? React with 👍 / 👎.

env: { ...process.env, PM2_HOME: pm2Home },
stdio: 'ignore',
timeout: 10_000,
});
rmSync(home, { recursive: true, force: true });
}
});

describe('PM2 read-only jlist stdout integrity', () => {
it('returns a large jlist without truncation', () => {
const home = tempHome();
const pm2Home = join(home, '.botmux', 'pm2');
mkdirSync(pm2Home, { recursive: true });
expect(spawnSync(process.execPath, [PM2_PATH, 'status'], {
env: { ...process.env, PM2_HOME: pm2Home },
stdio: 'ignore',
timeout: 10_000,
}).status).toBe(0);

const idleScript = join(home, 'idle.js');
writeFileSync(idleScript, 'setInterval(() => {}, 1000);\n');
for (let i = 0; i < 6; i++) {
const started = spawnSync(process.execPath, [PM2_PATH, 'start', idleScript, '--name', `flush-probe-${i}`], {
env: { ...process.env, PM2_HOME: pm2Home, BOTMUX_FLUSH_PROBE: 'x'.repeat(30_000) },
stdio: 'ignore',
timeout: 20_000,
});
expect(started.status).toBe(0);
}

const stdout = captureReadonlyPm2Jlist({ pkgRoot: PKG_ROOT, home: pm2Home });
// Keep the fixture well past the partial-flush range observed with a pipe.
expect(Buffer.byteLength(stdout)).toBeGreaterThan(200_000);
expect(JSON.parse(stdout)).toHaveLength(6);
}, 90_000);
});