diff --git a/src/cli.ts b/src/cli.ts index 9d8e34272..3bd68c5ac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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'; @@ -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 */ } } @@ -10737,6 +10749,7 @@ async function cmdAsk(sub: string, rest: string[]): Promise { // result.kind==='answered' 时用 toLegacySelected 取回旧的 string(单问单选) const selected = toLegacySelected(result); + let stdoutPayload: string | undefined; if (useJson) { const out: AskJsonOutput = { // `selected` 是「单问单选」的向后兼容值(= toLegacySelected 的形状判据: @@ -10749,7 +10762,7 @@ async function cmdAsk(sub: string, rest: string[]): Promise { 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。 // @@ -10766,7 +10779,19 @@ async function cmdAsk(sub: string, rest: string[]): Promise { // 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) { diff --git a/src/cli/pm2-readonly-client.ts b/src/cli/pm2-readonly-client.ts index 0cc6947f1..da60175a5 100644 --- a/src/cli/pm2-readonly-client.ts +++ b/src/cli/pm2-readonly-client.ts @@ -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; @@ -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; } diff --git a/src/cli/stdout-flush.ts b/src/cli/stdout-flush.ts new file mode 100644 index 000000000..7574aff87 --- /dev/null +++ b/src/cli/stdout-flush.ts @@ -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 { + return new Promise((resolve, reject) => { + stream.write(chunk, error => { + if (error) reject(error); + else resolve(); + }); + }); +} diff --git a/test/ask-cli.test.ts b/test/ask-cli.test.ts index aa104f0a8..a7fb09bdc 100644 --- a/test/ask-cli.test.ts +++ b/test/ask-cli.test.ts @@ -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((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((resolve, reject) => { + server.close((err) => err ? reject(err) : resolve()); + }); + } + }, 30_000); }); diff --git a/test/pm2-readonly-jlist.test.ts b/test/pm2-readonly-jlist.test.ts new file mode 100644 index 000000000..ad0b107d2 --- /dev/null +++ b/test/pm2-readonly-jlist.test.ts @@ -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'], { + 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); +});