From 9b19eb0d35a795c439664864dca46c78fe662467 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Wed, 29 Jul 2026 06:48:07 +0900 Subject: [PATCH] fix: wait for print output flush in bootstrap branch (stdin inherit + -p) Fixes silent exit 0 with 0-byte stdout/stderr when piping a prompt via stdin with CLAUDE_TERMUX_STDIN=inherit. The bootstrap branch lacked the output-flush wait that the helper branch already has, and the wait was missing from the synchronous process.exit() (RequestedExit) path in both branches. - Export CLAUDE_TERMUX_PRINT_MODE in shell before invoking bootstrap - Define waitForPrintFlush in helper branch outside try block for catch access - Define waitForPrintFlushIfNeeded in bootstrap branch outside try block - Call these functions after main execution and in RequestedExit handlers - Add static containment tests to verify presence of fix - Add runtime regression tests for normal/sync-exit/async-exit scenarios Co-Authored-By: Claude Sonnet 5 --- .../lib/termux-run-claude-native.sh | 23 ++++- .../lib/termux-run-claude-native.test.js | 98 ++++++++++++++++++- 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/packages/claude-code/lib/termux-run-claude-native.sh b/packages/claude-code/lib/termux-run-claude-native.sh index b4b0789..f14d79c 100755 --- a/packages/claude-code/lib/termux-run-claude-native.sh +++ b/packages/claude-code/lib/termux-run-claude-native.sh @@ -636,6 +636,12 @@ async function main() { function onAsyncError(error) { asyncErrors.push(error); } + const printWaitMs = Number(process.env.CLAUDE_TERMUX_PRINT_WAIT_MS || 5000); + async function waitForPrintFlush() { + if (Number.isFinite(printWaitMs) && printWaitMs > 0) { + await new Promise(resolve => setTimeout(resolve, printWaitMs)); + } + } try { process.once('uncaughtException', onAsyncError); @@ -697,18 +703,15 @@ async function main() { process.exit = code => { throw new RequestedExit(code); }; - - const printWaitMs = Number(process.env.CLAUDE_TERMUX_PRINT_WAIT_MS || 5000); const moduleLike = { exports: {} }; const maybePromise = fn(moduleLike.exports, fakeRequire, moduleLike, extractedFile, workdir); if (maybePromise && typeof maybePromise.then === 'function') await maybePromise; - if (Number.isFinite(printWaitMs) && printWaitMs > 0) { - await new Promise(resolve => setTimeout(resolve, printWaitMs)); - } + await waitForPrintFlush(); if (asyncErrors.length > 0) throw asyncErrors[0]; } catch (error) { if (error instanceof RequestedExit) { process.exitCode = error.code; + await waitForPrintFlush(); return; } throw error; @@ -758,6 +761,7 @@ else _bootstrap=$(mktemp "${TERMUX_TMPDIR}/claude-bootstrap.XXXXXX.js") trap 'rm -f "$_bootstrap"' EXIT HUP INT TERM export CLAUDE_TERMUX_TUI="${_tui}" + export CLAUDE_TERMUX_PRINT_MODE="${_pf}" cat <<'NODE' > "$_bootstrap" const fs = require('fs'); const path = require('path'); @@ -1329,6 +1333,13 @@ async function main() { function onAsyncError(error) { asyncErrors.push(error); } + async function waitForPrintFlushIfNeeded() { + if (process.env.CLAUDE_TERMUX_PRINT_MODE !== '1') return; + const printWaitMs = Number(process.env.CLAUDE_TERMUX_PRINT_WAIT_MS || 5000); + if (Number.isFinite(printWaitMs) && printWaitMs > 0) { + await new Promise(resolve => setTimeout(resolve, printWaitMs)); + } + } try { process.once('uncaughtException', onAsyncError); @@ -1394,10 +1405,12 @@ async function main() { const moduleLike = { exports: {} }; const maybePromise = fn(moduleLike.exports, fakeRequire, moduleLike, extractedFile, workdir); if (maybePromise && typeof maybePromise.then === 'function') await maybePromise; + await waitForPrintFlushIfNeeded(); if (asyncErrors.length > 0) throw asyncErrors[0]; } catch (error) { if (error instanceof RequestedExit) { process.exitCode = error.code; + await waitForPrintFlushIfNeeded(); return; } throw error; diff --git a/packages/claude-code/lib/termux-run-claude-native.test.js b/packages/claude-code/lib/termux-run-claude-native.test.js index 122bca4..adc5e30 100644 --- a/packages/claude-code/lib/termux-run-claude-native.test.js +++ b/packages/claude-code/lib/termux-run-claude-native.test.js @@ -191,12 +191,32 @@ test('print path does not defer cleanup to exit', () => { test('bootstrap path defers cleanup to exit', () => { const bootstrapBlock = extractBlock('cat <<\'NODE\' > "$_bootstrap"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); - assert.equal(bootstrapBlock.includes('CLAUDE_TERMUX_PRINT_WAIT_MS'), false); - assert.equal(bootstrapBlock.includes('setTimeout(resolve, printWaitMs)'), false); assert.equal(bootstrapBlock.includes('process.once(\'exit\''), true); assert.equal(bootstrapBlock.includes('process.removeListener(\'uncaughtException\''), true); }); +test('bootstrap branch exports CLAUDE_TERMUX_PRINT_MODE before invoking node', () => { + const bootstrapShellRegion = extractFunction( + script, + 'export CLAUDE_TERMUX_TUI="${_tui}"', + 'cat <<\'NODE\' > "$_bootstrap"', + ); + assert.equal(bootstrapShellRegion.includes('export CLAUDE_TERMUX_PRINT_MODE="${_pf}"'), true); +}); + +test('helper and bootstrap wait for print flush, including the RequestedExit path', () => { + const helperBlock = extractBlock('cat <<\'NODE\' > "$_helper"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); + const bootstrapBlock = extractBlock('cat <<\'NODE\' > "$_bootstrap"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); + + assert.equal(helperBlock.includes('async function waitForPrintFlush()'), true); + assert.equal(helperBlock.includes('await waitForPrintFlush();\n if (asyncErrors.length > 0) throw asyncErrors[0];'), true); + assert.equal(helperBlock.includes('process.exitCode = error.code;\n await waitForPrintFlush();\n return;'), true); + + assert.equal(bootstrapBlock.includes('async function waitForPrintFlushIfNeeded()'), true); + assert.equal(bootstrapBlock.includes('await waitForPrintFlushIfNeeded();\n if (asyncErrors.length > 0) throw asyncErrors[0];'), true); + assert.equal(bootstrapBlock.includes('process.exitCode = error.code;\n await waitForPrintFlushIfNeeded();\n return;'), true); +}); + test('entry extraction uses a process-unique filename', () => { const helperBlock = extractBlock('cat <<\'NODE\' > "$_helper"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); const bootstrapBlock = extractBlock('cat <<\'NODE\' > "$_bootstrap"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); @@ -544,3 +564,77 @@ test('tarball contents match the workspace runner and test file', { skip: !fs.ex test('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is never forced (regression guard)', () => { assert.equal(script.includes('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'), false); }); + +function buildScenarioFixtureSource() { + const typeofBun = Array.from({ length: 7 }, () => 'typeof Bun').join('; '); + const bunProps = Array.from({ length: 42 }, (_, i) => `Bun.p${i}`).join('; '); + return `function(exports, require, module, __filename, __dirname) { + ${typeofBun}; typeof globalThis.Bun; globalThis.Bun; ${bunProps}; + npmInstallDeprecated:!0; npmInstallDeprecated:!0; + const scenario = process.env.TEST_SCENARIO; + if (scenario === 'sync-exit') { process.stdout.write('ok'); process.exit(0); return; } + if (scenario === 'async-exit') { + setTimeout(() => { process.stdout.write('ok'); }, 100).unref(); + return; + } + process.stdout.write('ok'); + }`; +} + +function runScenario({ printMode, stdinInherit, scenario }) { + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-stdin-test-')); + const sourceBin = path.join(tmpBase, 'fake-source.js'); + const fixtureSource = buildScenarioFixtureSource(); + fs.writeFileSync(sourceBin, fixtureSource, 'utf8'); + const entryJsOffset = 0; + const entryEndOffset = Buffer.byteLength(fixtureSource, 'utf8'); + const workdir = path.join(tmpBase, 'workdir'); + fs.mkdirSync(workdir, { recursive: true }); + + const env = { + ...process.env, + SOURCE_BIN: sourceBin, + WORKDIR: workdir, + ENTRY_JS_OFFSET: String(entryJsOffset), + ENTRY_END_OFFSET: String(entryEndOffset), + CURRENT_CLAUDE_VERSION: '2.1.220', + CLAUDE_TERMUX_PACKAGE_DIR: path.join(__dirname, '..'), + MAGI_ENV: '1', + CLAUDE_TERMUX_PRINT_WAIT_MS: '300', + TMPDIR: tmpBase, + TEST_SCENARIO: scenario, + }; + if (stdinInherit) env.CLAUDE_TERMUX_STDIN = 'inherit'; + else delete env.CLAUDE_TERMUX_STDIN; + + const args = printMode ? ['-p', 'x'] : []; + const start = Date.now(); + const result = child_process.spawnSync('sh', [scriptPath, ...args], { + env, + input: 'test input\n', + encoding: 'utf8', + timeout: 10000, + }); + const elapsedMs = Date.now() - start; + fs.rmSync(tmpBase, { recursive: true, force: true }); + return { ...result, elapsedMs }; +} + +test('helper branch (CLI -p, no stdin inherit): normal/sync-exit/async-exit all produce output', () => { + for (const scenario of ['normal', 'sync-exit', 'async-exit']) { + const r = runScenario({ printMode: true, stdinInherit: false, scenario }); + assert.ok((r.stdout || '').includes('ok'), `scenario=${scenario} stdout=${r.stdout} stderr=${r.stderr}`); + assert.equal(r.status, 0, `scenario=${scenario} status=${r.status} stderr=${r.stderr}`); + } +}); + +test('bootstrap branch (-p + CLAUDE_TERMUX_STDIN=inherit): normal/sync-exit/async-exit all produce output', () => { + for (const scenario of ['normal', 'sync-exit', 'async-exit']) { + const r = runScenario({ printMode: true, stdinInherit: true, scenario }); + assert.ok((r.stdout || '').includes('ok'), `scenario=${scenario} stdout=${r.stdout} stderr=${r.stderr}`); + assert.equal(r.status, 0, `scenario=${scenario} status=${r.status} stderr=${r.stderr}`); + if (scenario === 'async-exit') { + assert.ok(r.elapsedMs >= 250, `expected wait >= 250ms, got ${r.elapsedMs}ms`); + } + } +});