From f042090dd5c11a6835a465054f046a82d7cae139 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Thu, 30 Jul 2026 08:31:16 +0900 Subject: [PATCH 1/2] wip: stream-json terminal watcher (unref removed, clearTimeout missing) Intermediate state from Haiku implementation, saved before further fixes. Known issue: timeout timer is ref'd but never cleared when result is detected, causing successful stream-json runs to hang until timeout. Co-Authored-By: Claude Sonnet 5 --- .../lib/termux-run-claude-native.sh | 215 +++++++ .../lib/termux-run-claude-native.test.js | 548 +++++++++++++++++- 2 files changed, 745 insertions(+), 18 deletions(-) diff --git a/packages/claude-code/lib/termux-run-claude-native.sh b/packages/claude-code/lib/termux-run-claude-native.sh index bd7738a..959b946 100755 --- a/packages/claude-code/lib/termux-run-claude-native.sh +++ b/packages/claude-code/lib/termux-run-claude-native.sh @@ -79,6 +79,22 @@ const entryJsOffset = Number(process.env.ENTRY_JS_OFFSET); const entryEndOffset = Number(process.env.ENTRY_END_OFFSET); const argv = process.argv.slice(2); +function isStreamJsonPrintMode(argv) { + const dashDashIndex = argv.indexOf('--'); + const ownArgs = dashDashIndex === -1 ? argv : argv.slice(0, dashDashIndex); + const hasPrintFlag = ownArgs.includes('-p') || ownArgs.includes('--print'); + let hasStreamJsonFormat = false; + for (let i = 0; i < ownArgs.length; i++) { + const tok = ownArgs[i]; + if (tok === '--output-format=stream-json') { hasStreamJsonFormat = true; break; } + if (tok === '--output-format' && ownArgs[i + 1] === 'stream-json') { + hasStreamJsonFormat = true; + break; + } + } + return hasPrintFlag && hasStreamJsonFormat; +} + class RequestedExit extends Error { constructor(code) { super(`process.exit ${code}`); @@ -628,6 +644,7 @@ async function main() { const hadGlobalBun = Object.prototype.hasOwnProperty.call(globalThis, 'Bun'); const originalGlobalBun = globalThis.Bun; const asyncErrors = []; + let streamJsonWatcher = null; globalThis.__claudeYaml = createYamlShim(); if (!globalThis.__claudeBunShim || typeof globalThis.__claudeBunShim !== 'object') { @@ -638,7 +655,90 @@ async function main() { asyncErrors.push(error); } const printWaitMs = Number(process.env.CLAUDE_TERMUX_PRINT_WAIT_MS || 5000); + + function installStreamJsonTerminalWatcher() { + const hadOwnWrite = Object.prototype.hasOwnProperty.call(process.stdout, 'write'); + const originalWriteDescriptor = hadOwnWrite ? Object.getOwnPropertyDescriptor(process.stdout, 'write') : undefined; + const originalWrite = process.stdout.write.bind(process.stdout); + const { StringDecoder } = require('string_decoder'); + const decoder = new StringDecoder('utf8'); + let lineBuffer = ''; + let resultPromiseResolve = null; + let foundResult = false; + let restored = false; + + process.stdout.write = function wrappedWrite(chunk, encoding, callback) { + let cb = callback; + let enc = encoding; + if (typeof encoding === 'function') { + cb = encoding; + enc = undefined; + } + const combinedCallback = (err) => { + if (!err && !foundResult) { + const chunkStr = typeof chunk === 'string' + ? decoder.write(Buffer.from(chunk, typeof enc === 'string' ? enc : 'utf8')) + : decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + lineBuffer += chunkStr; + const lines = lineBuffer.split('\n'); + lineBuffer = lines[lines.length - 1]; + for (let i = 0; i < lines.length - 1; i++) { + try { + const parsed = JSON.parse(lines[i]); + if (parsed && parsed.type === 'result') { + foundResult = true; + if (typeof resultPromiseResolve === 'function') resultPromiseResolve(); + } + } catch {} + } + } + if (typeof cb === 'function') cb(err); + }; + return originalWrite(chunk, enc, combinedCallback); + }; + + return { + waitForResult() { + if (foundResult) return Promise.resolve(); + return new Promise((resolve, reject) => { + resultPromiseResolve = resolve; + }); + }, + restore() { + if (restored) return; + restored = true; + if (hadOwnWrite) { + Object.defineProperty(process.stdout, 'write', originalWriteDescriptor); + } else { + delete process.stdout.write; + } + }, + }; + } + + function forceTimeoutExit(exitCode) { + try { if (streamJsonWatcher) streamJsonWatcher.restore(); } catch {} + if (extractedFile) { + try { fs.rmSync(extractedFile, { force: true }); } catch {} + } + originalExit(exitCode); + } + async function waitForPrintFlush() { + if (isStreamJsonPrintMode(argv) && streamJsonWatcher) { + let timedOut = false; + const rawResultTimeoutMs = Number(process.env.CLAUDE_TERMUX_PRINT_RESULT_TIMEOUT_MS); + const resultTimeoutMs = (Number.isFinite(rawResultTimeoutMs) && rawResultTimeoutMs > 0) ? rawResultTimeoutMs : 300000; + const timeoutPromise = new Promise(resolve => { + setTimeout(() => { timedOut = true; resolve(); }, resultTimeoutMs); + }); + await Promise.race([streamJsonWatcher.waitForResult(), timeoutPromise]); + if (timedOut) { + forceTimeoutExit(1); + return; + } + return; + } if (Number.isFinite(printWaitMs) && printWaitMs > 0) { await new Promise(resolve => setTimeout(resolve, printWaitMs)); } @@ -712,6 +812,11 @@ async function main() { } return originalKill.call(process, pid, signal); }; + + if (isStreamJsonPrintMode(argv)) { + streamJsonWatcher = installStreamJsonTerminalWatcher(); + } + const moduleLike = { exports: {} }; const maybePromise = fn(moduleLike.exports, fakeRequire, moduleLike, extractedFile, workdir); if (maybePromise && typeof maybePromise.then === 'function') await maybePromise; @@ -735,6 +840,9 @@ async function main() { process.argv = originalArgv; process.exit = originalExit; process.kill = originalKill; + if (streamJsonWatcher) { + try { streamJsonWatcher.restore(); } catch {} + } try { if (originalBun === undefined) { delete process.versions.bun; @@ -786,6 +894,22 @@ const entryJsOffset = Number(process.env.ENTRY_JS_OFFSET); const entryEndOffset = Number(process.env.ENTRY_END_OFFSET); const argv = process.argv.slice(2); +function isStreamJsonPrintMode(argv) { + const dashDashIndex = argv.indexOf('--'); + const ownArgs = dashDashIndex === -1 ? argv : argv.slice(0, dashDashIndex); + const hasPrintFlag = ownArgs.includes('-p') || ownArgs.includes('--print'); + let hasStreamJsonFormat = false; + for (let i = 0; i < ownArgs.length; i++) { + const tok = ownArgs[i]; + if (tok === '--output-format=stream-json') { hasStreamJsonFormat = true; break; } + if (tok === '--output-format' && ownArgs[i + 1] === 'stream-json') { + hasStreamJsonFormat = true; + break; + } + } + return hasPrintFlag && hasStreamJsonFormat; +} + class RequestedExit extends Error { constructor(code) { super(`process.exit ${code}`); @@ -1335,6 +1459,7 @@ async function main() { const hadGlobalBun = Object.prototype.hasOwnProperty.call(globalThis, 'Bun'); const originalGlobalBun = globalThis.Bun; const asyncErrors = []; + let streamJsonWatcher = null; globalThis.__claudeYaml = createYamlShim(); if (!globalThis.__claudeBunShim || typeof globalThis.__claudeBunShim !== 'object') { @@ -1344,8 +1469,91 @@ async function main() { function onAsyncError(error) { asyncErrors.push(error); } + + function installStreamJsonTerminalWatcher() { + const hadOwnWrite = Object.prototype.hasOwnProperty.call(process.stdout, 'write'); + const originalWriteDescriptor = hadOwnWrite ? Object.getOwnPropertyDescriptor(process.stdout, 'write') : undefined; + const originalWrite = process.stdout.write.bind(process.stdout); + const { StringDecoder } = require('string_decoder'); + const decoder = new StringDecoder('utf8'); + let lineBuffer = ''; + let resultPromiseResolve = null; + let foundResult = false; + let restored = false; + + process.stdout.write = function wrappedWrite(chunk, encoding, callback) { + let cb = callback; + let enc = encoding; + if (typeof encoding === 'function') { + cb = encoding; + enc = undefined; + } + const combinedCallback = (err) => { + if (!err && !foundResult) { + const chunkStr = typeof chunk === 'string' + ? decoder.write(Buffer.from(chunk, typeof enc === 'string' ? enc : 'utf8')) + : decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + lineBuffer += chunkStr; + const lines = lineBuffer.split('\n'); + lineBuffer = lines[lines.length - 1]; + for (let i = 0; i < lines.length - 1; i++) { + try { + const parsed = JSON.parse(lines[i]); + if (parsed && parsed.type === 'result') { + foundResult = true; + if (typeof resultPromiseResolve === 'function') resultPromiseResolve(); + } + } catch {} + } + } + if (typeof cb === 'function') cb(err); + }; + return originalWrite(chunk, enc, combinedCallback); + }; + + return { + waitForResult() { + if (foundResult) return Promise.resolve(); + return new Promise((resolve, reject) => { + resultPromiseResolve = resolve; + }); + }, + restore() { + if (restored) return; + restored = true; + if (hadOwnWrite) { + Object.defineProperty(process.stdout, 'write', originalWriteDescriptor); + } else { + delete process.stdout.write; + } + }, + }; + } + + function forceTimeoutExit(exitCode) { + try { if (streamJsonWatcher) streamJsonWatcher.restore(); } catch {} + if (extractedFile) { + try { fs.rmSync(extractedFile, { force: true }); } catch {} + } + originalExit(exitCode); + } + async function waitForPrintFlushIfNeeded() { if (process.env.CLAUDE_TERMUX_PRINT_MODE !== '1') return; + if (isStreamJsonPrintMode(argv) && streamJsonWatcher) { + let timedOut = false; + const rawResultTimeoutMs = Number(process.env.CLAUDE_TERMUX_PRINT_RESULT_TIMEOUT_MS); + const resultTimeoutMs = (Number.isFinite(rawResultTimeoutMs) && rawResultTimeoutMs > 0) ? rawResultTimeoutMs : 300000; + const timeoutPromise = new Promise(resolve => { + setTimeout(() => { timedOut = true; resolve(); }, resultTimeoutMs); + }); + await Promise.race([streamJsonWatcher.waitForResult(), timeoutPromise]); + if (timedOut) { + forceTimeoutExit(1); + return; + } + 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)); @@ -1421,6 +1629,10 @@ async function main() { return originalKill.call(process, pid, signal); }; + if (isStreamJsonPrintMode(argv)) { + streamJsonWatcher = installStreamJsonTerminalWatcher(); + } + const moduleLike = { exports: {} }; const maybePromise = fn(moduleLike.exports, fakeRequire, moduleLike, extractedFile, workdir); if (maybePromise && typeof maybePromise.then === 'function') await maybePromise; @@ -1439,6 +1651,9 @@ async function main() { process.argv = originalArgv; process.exit = originalExit; process.kill = originalKill; + if (streamJsonWatcher) { + try { streamJsonWatcher.restore(); } catch {} + } process.once('exit', () => { if (extractedFile) { try { 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 609360a..d96d6d1 100644 --- a/packages/claude-code/lib/termux-run-claude-native.test.js +++ b/packages/claude-code/lib/termux-run-claude-native.test.js @@ -610,11 +610,39 @@ function buildScenarioFixtureSource() { process.stdout.write('ok'); return; } + if (scenario === 'stream-json-result') { + process.stdout.write('{"type":"init"}\\n'); + const msg = '{"type":"result","data":"test"}\\n'; + process.stdout.write(msg, undefined, () => {}); + return; + } + if (scenario === 'stream-json-multibyte-split') { + // Split a UTF-8 multi-byte character across write calls + // 'あ' is 3 bytes in UTF-8: e3 81 82 + const buf = Buffer.from('あ', 'utf8'); + // Split the 3-byte character: first byte in one write, remaining in another + const jsonLine = '{"type":"result"}\\n'; + const part1 = Buffer.concat([Buffer.from(jsonLine), buf.slice(0, 1)]); + const part2 = Buffer.concat([buf.slice(1)]); + process.stdout.write(part1); + process.stdout.write(part2, undefined, () => {}); + return; + } + if (scenario === 'stream-json-timeout') { + process.stdout.write('{"type":"init"}\\n'); + setTimeout(() => {}, 5000); + return; + } + if (scenario === 'stream-json-requested-exit-then-result') { + process.stdout.write('{"type":"result"}\\n'); + process.exit(0); + return; + } process.stdout.write('ok'); }`; } -function runScenario({ printMode, stdinInherit, scenario }) { +function runScenario({ printMode, stdinInherit, scenario, extraArgs }) { const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-stdin-test-')); const sourceBin = path.join(tmpBase, 'fake-source.js'); const fixtureSource = buildScenarioFixtureSource(); @@ -640,7 +668,7 @@ function runScenario({ printMode, stdinInherit, scenario }) { if (stdinInherit) env.CLAUDE_TERMUX_STDIN = 'inherit'; else delete env.CLAUDE_TERMUX_STDIN; - const args = printMode ? ['-p', 'x'] : []; + const args = printMode ? ['-p', 'x', ...(extraArgs || [])] : []; const start = Date.now(); const result = child_process.spawnSync('sh', [scriptPath, ...args], { env, @@ -649,8 +677,7 @@ function runScenario({ printMode, stdinInherit, scenario }) { timeout: 10000, }); const elapsedMs = Date.now() - start; - fs.rmSync(tmpBase, { recursive: true, force: true }); - return { ...result, elapsedMs }; + return { ...result, elapsedMs, tmpBase }; } test('helper and bootstrap intercept process.kill(self, SIGKILL) statically', () => { @@ -695,42 +722,527 @@ test('helper and bootstrap intercept process.kill(self, SIGKILL) statically', () 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}`); + try { + 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}`); + } finally { + fs.rmSync(r.tmpBase, { recursive: true, force: true }); + } } }); 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`); + try { + 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`); + } + } finally { + fs.rmSync(r.tmpBase, { recursive: true, force: true }); } } }); test('helper branch intercepts self-directed SIGKILL (string signal) and exits with proper code', () => { const r = runScenario({ printMode: true, stdinInherit: false, scenario: 'self-sigkill-fallback-string' }); - assert.equal(r.status, 17, `expected status 17, got ${r.status}; stderr=${r.stderr}`); - assert.ok(!r.signal, `expected clean exit (no signal), but got signal: ${r.signal}`); + try { + assert.equal(r.status, 17, `expected status 17, got ${r.status}; stderr=${r.stderr}`); + assert.ok(!r.signal, `expected clean exit (no signal), but got signal: ${r.signal}`); + } finally { + fs.rmSync(r.tmpBase, { recursive: true, force: true }); + } }); test('helper branch intercepts self-directed SIGKILL (numeric signal 9) and exits with proper code', () => { const r = runScenario({ printMode: true, stdinInherit: false, scenario: 'self-sigkill-fallback-numeric' }); - assert.equal(r.status, 17, `expected status 17, got ${r.status}; stderr=${r.stderr}`); - assert.ok(!r.signal, `expected clean exit (no signal), but got signal: ${r.signal}`); + try { + assert.equal(r.status, 17, `expected status 17, got ${r.status}; stderr=${r.stderr}`); + assert.ok(!r.signal, `expected clean exit (no signal), but got signal: ${r.signal}`); + } finally { + fs.rmSync(r.tmpBase, { recursive: true, force: true }); + } }); test('helper branch intercepts self-directed SIGKILL with process.exit() (no code) and defaults to 0', () => { const r = runScenario({ printMode: true, stdinInherit: false, scenario: 'self-sigkill-fallback-no-code' }); - assert.equal(r.status, 0, `expected status 0, got ${r.status}; stderr=${r.stderr}`); - assert.ok(!r.signal, `expected clean exit (no signal), but got signal: ${r.signal}`); + try { + assert.equal(r.status, 0, `expected status 0, got ${r.status}; stderr=${r.stderr}`); + assert.ok(!r.signal, `expected clean exit (no signal), but got signal: ${r.signal}`); + } finally { + fs.rmSync(r.tmpBase, { recursive: true, force: true }); + } }); test('helper branch allows process.kill to other processes (signal 0, passthrough)', () => { const r = runScenario({ printMode: true, stdinInherit: false, scenario: 'other-process-kill' }); - assert.ok((r.stdout || '').includes('ok'), `expected ok output, got stdout=${r.stdout} stderr=${r.stderr}`); - assert.equal(r.status, 0, `expected status 0, got ${r.status}; stderr=${r.stderr}`); + try { + assert.ok((r.stdout || '').includes('ok'), `expected ok output, got stdout=${r.stdout} stderr=${r.stderr}`); + assert.equal(r.status, 0, `expected status 0, got ${r.status}; stderr=${r.stderr}`); + } finally { + fs.rmSync(r.tmpBase, { recursive: true, force: true }); + } +}); + +test('isStreamJsonPrintMode detects print flag and stream-json format (case 1: -p + format + value)', () => { + const helperBlock = extractBlock('cat <<\'NODE\' > "$_helper"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); + const fnSource = extractFunction(helperBlock, 'function isStreamJsonPrintMode(argv) {', '\n\nclass RequestedExit'); + const context = vm.createContext({ module: { exports: {} } }); + vm.runInContext(`${fnSource}\nmodule.exports = isStreamJsonPrintMode;`, context); + const isStreamJsonPrintMode = context.module.exports; + + assert.equal(isStreamJsonPrintMode(['-p', 'hello', '--output-format', 'stream-json']), true, 'case 1'); + assert.equal(isStreamJsonPrintMode(['-p', 'hello', '--output-format=stream-json']), true, 'case 2'); + assert.equal(isStreamJsonPrintMode(['--print', '--output-format=stream-json']), true, 'case 3'); + assert.equal(isStreamJsonPrintMode(['-p', 'hello', '--output-format', 'json']), false, 'case 4'); + assert.equal(isStreamJsonPrintMode(['-p']), false, 'case 5'); + assert.equal(isStreamJsonPrintMode(['--output-format=stream-json']), false, 'case 6'); + assert.equal(isStreamJsonPrintMode(['-p', '--', '--output-format=stream-json']), false, 'case 7'); + assert.equal(isStreamJsonPrintMode(['-p', '--output-format=stream-json', '--', 'extra']), true, 'case 8'); + assert.equal(isStreamJsonPrintMode(['-p', '--output-format', '--', 'stream-json']), false, 'case 9'); + assert.equal(isStreamJsonPrintMode(['-p', '--output-format']), false, 'case 10'); + assert.equal(isStreamJsonPrintMode([]), false, 'case 11'); +}); + +test('isStreamJsonPrintMode is identical in helper and bootstrap', () => { + const helperBlock = extractBlock('cat <<\'NODE\' > "$_helper"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); + const bootstrapBlock = extractBlock('cat <<\'NODE\' > "$_bootstrap"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); + + const helperFn = extractFunction(helperBlock, 'function isStreamJsonPrintMode(argv) {', '\n\nclass RequestedExit'); + const bootstrapFn = extractFunction(bootstrapBlock, 'function isStreamJsonPrintMode(argv) {', '\n\nclass RequestedExit'); + + assert.equal(helperFn, bootstrapFn, 'isStreamJsonPrintMode must be identical in both heredocs'); +}); + +test('CLAUDE_TERMUX_PRINT_RESULT_TIMEOUT_MS fallback to 300000 on NaN', () => { + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-timeout-test-')); + try { + 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', + CLAUDE_TERMUX_PRINT_RESULT_TIMEOUT_MS: 'invalid', + TMPDIR: tmpBase, + TEST_SCENARIO: 'stream-json-result', + }; + delete env.CLAUDE_TERMUX_STDIN; + + const result = child_process.spawnSync('sh', [scriptPath, '-p', 'x', '--output-format=stream-json'], { + env, + input: 'test input\n', + encoding: 'utf8', + timeout: 10000, + }); + assert.equal(result.status, 0, `expected successful exit with invalid timeout fallback, got status=${result.status} stderr=${result.stderr}`); + } finally { + fs.rmSync(tmpBase, { recursive: true, force: true }); + } +}); + +test('CLAUDE_TERMUX_PRINT_RESULT_TIMEOUT_MS fallback to 300000 on zero', () => { + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-timeout-test-')); + try { + 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', + CLAUDE_TERMUX_PRINT_RESULT_TIMEOUT_MS: '0', + TMPDIR: tmpBase, + TEST_SCENARIO: 'stream-json-result', + }; + delete env.CLAUDE_TERMUX_STDIN; + + const result = child_process.spawnSync('sh', [scriptPath, '-p', 'x', '--output-format=stream-json'], { + env, + input: 'test input\n', + encoding: 'utf8', + timeout: 10000, + }); + assert.equal(result.status, 0, `expected successful exit with zero timeout fallback, got status=${result.status} stderr=${result.stderr}`); + } finally { + fs.rmSync(tmpBase, { recursive: true, force: true }); + } +}); + +test('helper and bootstrap installStreamJsonTerminalWatcher helpers stay identical', () => { + const helperBlock = extractBlock('cat <<\'NODE\' > "$_helper"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); + const bootstrapBlock = extractBlock('cat <<\'NODE\' > "$_bootstrap"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); + + const helperWatcher = extractFunction( + helperBlock, + 'function installStreamJsonTerminalWatcher() {', + '\n function forceTimeoutExit', + ); + const bootstrapWatcher = extractFunction( + bootstrapBlock, + 'function installStreamJsonTerminalWatcher() {', + '\n function forceTimeoutExit', + ); + + assert.equal(helperWatcher, bootstrapWatcher, 'installStreamJsonTerminalWatcher must be identical in both heredocs'); +}); + +test('helper and bootstrap forceTimeoutExit helpers stay identical', () => { + const helperBlock = extractBlock('cat <<\'NODE\' > "$_helper"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); + const bootstrapBlock = extractBlock('cat <<\'NODE\' > "$_bootstrap"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); + + const helperForceExit = extractFunction( + helperBlock, + 'function forceTimeoutExit(exitCode) {', + '\n async function waitForPrintFlush', + ); + const bootstrapForceExit = extractFunction( + bootstrapBlock, + 'function forceTimeoutExit(exitCode) {', + '\n async function waitForPrintFlushIfNeeded', + ); + + assert.equal(helperForceExit, bootstrapForceExit, 'forceTimeoutExit must be identical in both heredocs'); +}); + +test('installStreamJsonTerminalWatcher restores process.stdout.write own property state', () => { + // Test with the real process.stdout to verify own property handling + const hadOwnPropertyBefore = Object.prototype.hasOwnProperty.call(process.stdout, 'write'); + const descriptorBefore = hadOwnPropertyBefore ? Object.getOwnPropertyDescriptor(process.stdout, 'write') : undefined; + + // Get the watcher function from helper + const helperBlock = extractBlock('cat <<\'NODE\' > "$_helper"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); + const watcherSource = extractFunction(helperBlock, 'function installStreamJsonTerminalWatcher() {', '\n function forceTimeoutExit'); + + const context = vm.createContext({ + module: { exports: {} }, + process, + Object, + Buffer, + require: (id) => { + if (id === 'string_decoder') return require('string_decoder'); + throw new Error('require not available'); + }, + }); + + vm.runInContext(` + ${watcherSource} + module.exports = installStreamJsonTerminalWatcher; + `, context); + + const installStreamJsonTerminalWatcher = context.module.exports; + + try { + // Test case 1: Normal case where write is not an own property + { + const watcher = installStreamJsonTerminalWatcher(); + const hadOwnPropertyAfterInstall = Object.prototype.hasOwnProperty.call(process.stdout, 'write'); + assert.equal(hadOwnPropertyAfterInstall, true, 'after install: process.stdout.write should be own property'); + + // Restore watcher + watcher.restore(); + const hadOwnPropertyAfterRestore = Object.prototype.hasOwnProperty.call(process.stdout, 'write'); + assert.equal(hadOwnPropertyAfterRestore, hadOwnPropertyBefore, 'after restore: own property state should match initial'); + + // Verify restore is idempotent + watcher.restore(); + const hadOwnPropertyAfterSecondRestore = Object.prototype.hasOwnProperty.call(process.stdout, 'write'); + assert.equal(hadOwnPropertyAfterSecondRestore, hadOwnPropertyBefore, 'second restore should also maintain initial state'); + } + + // Test case 2: When write is an own property before installation + { + const testDescriptor = { + value: function testWrite() { return true; }, + writable: true, + configurable: true, + enumerable: false, + }; + Object.defineProperty(process.stdout, 'write', testDescriptor); + + const watcher = installStreamJsonTerminalWatcher(); + const hadOwnAfterInstall = Object.prototype.hasOwnProperty.call(process.stdout, 'write'); + assert.equal(hadOwnAfterInstall, true, 'test case 2: after install should have own property'); + + watcher.restore(); + const hadOwnAfterRestore = Object.prototype.hasOwnProperty.call(process.stdout, 'write'); + assert.equal(hadOwnAfterRestore, true, 'test case 2: after restore should still have own property'); + + const restoredDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'write'); + assert.equal(typeof restoredDescriptor.value, 'function', 'test case 2: restored value should be a function'); + assert.equal(restoredDescriptor.configurable, true, 'test case 2: restored configurable should match'); + } + } finally { + // Ensure stdout.write is fully restored to original state + if (hadOwnPropertyBefore && descriptorBefore) { + Object.defineProperty(process.stdout, 'write', descriptorBefore); + } else if (Object.prototype.hasOwnProperty.call(process.stdout, 'write')) { + delete process.stdout.write; + } + } +}); + +test('helper branch stream-json result detection (single write)', () => { + const r = runScenario({ + printMode: true, + stdinInherit: false, + scenario: 'stream-json-result', + extraArgs: ['--output-format=stream-json'], + }); + try { + assert.equal(r.status, 0, `expected status 0, got ${r.status}; stderr=${r.stderr}`); + // Result detection should be significantly faster than traditional PRINT_WAIT_MS (300ms in tests) + assert.ok(r.elapsedMs < 1500, `expected completion < 1500ms (much faster than 300ms PRINT_WAIT_MS), got ${r.elapsedMs}ms`); + } finally { + fs.rmSync(r.tmpBase, { recursive: true, force: true }); + } +}); + +test('bootstrap branch stream-json result detection (single write)', () => { + const r = runScenario({ + printMode: true, + stdinInherit: true, + scenario: 'stream-json-result', + extraArgs: ['--output-format=stream-json'], + }); + try { + assert.equal(r.status, 0, `expected status 0, got ${r.status}; stderr=${r.stderr}`); + // Result detection should be significantly faster than traditional PRINT_WAIT_MS (300ms in tests) + assert.ok(r.elapsedMs < 1500, `expected completion < 1500ms (much faster than 300ms PRINT_WAIT_MS), got ${r.elapsedMs}ms`); + } finally { + fs.rmSync(r.tmpBase, { recursive: true, force: true }); + } +}); + +test('helper branch stream-json multibyte character split handling', () => { + const r = runScenario({ + printMode: true, + stdinInherit: false, + scenario: 'stream-json-multibyte-split', + extraArgs: ['--output-format=stream-json'], + }); + try { + assert.equal(r.status, 0, `expected status 0, got ${r.status}; stderr=${r.stderr}`); + assert.ok(r.elapsedMs < 6000, `expected completion < 6000ms, got ${r.elapsedMs}ms`); + } finally { + fs.rmSync(r.tmpBase, { recursive: true, force: true }); + } +}); + +test('bootstrap branch stream-json multibyte character split handling', () => { + const r = runScenario({ + printMode: true, + stdinInherit: true, + scenario: 'stream-json-multibyte-split', + extraArgs: ['--output-format=stream-json'], + }); + try { + assert.equal(r.status, 0, `expected status 0, got ${r.status}; stderr=${r.stderr}`); + assert.ok(r.elapsedMs < 6000, `expected completion < 6000ms, got ${r.elapsedMs}ms`); + } finally { + fs.rmSync(r.tmpBase, { recursive: true, force: true }); + } +}); + +test('helper branch stream-json timeout triggers exit with status 1', () => { + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-stream-timeout-')); + try { + 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', + CLAUDE_TERMUX_PRINT_RESULT_TIMEOUT_MS: '300', + TMPDIR: tmpBase, + TEST_SCENARIO: 'stream-json-timeout', + }; + delete env.CLAUDE_TERMUX_STDIN; + + const result = child_process.spawnSync('sh', [scriptPath, '-p', 'x', '--output-format=stream-json'], { + env, + input: 'test input\n', + encoding: 'utf8', + timeout: 5000, + }); + + assert.equal(result.status, 1, `expected status 1 on timeout, got ${result.status}; stderr=${result.stderr}`); + const entries = fs.readdirSync(workdir, { withFileTypes: true }); + const entryFiles = entries.filter(e => e.name.includes('cli.') && e.name.endsWith('.bare-path.js')); + assert.equal(entryFiles.length, 0, `expected no extracted entry files after timeout, found ${entryFiles.length}`); + } finally { + fs.rmSync(tmpBase, { recursive: true, force: true }); + } +}); + +test('bootstrap branch stream-json timeout triggers exit with status 1', () => { + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-stream-timeout-')); + try { + 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', + CLAUDE_TERMUX_PRINT_RESULT_TIMEOUT_MS: '300', + CLAUDE_TERMUX_STDIN: 'inherit', + TMPDIR: tmpBase, + TEST_SCENARIO: 'stream-json-timeout', + }; + + const result = child_process.spawnSync('sh', [scriptPath, '-p', 'x', '--output-format=stream-json'], { + env, + input: 'test input\n', + encoding: 'utf8', + timeout: 5000, + }); + + assert.equal(result.status, 1, `expected status 1 on timeout, got ${result.status}; stderr=${result.stderr}`); + const entries = fs.readdirSync(workdir, { withFileTypes: true }); + const entryFiles = entries.filter(e => e.name.includes('cli.') && e.name.endsWith('.bare-path.js')); + assert.equal(entryFiles.length, 0, `expected no extracted entry files after timeout, found ${entryFiles.length}`); + } finally { + fs.rmSync(tmpBase, { recursive: true, force: true }); + } +}); + +test('helper branch stream-json requested exit after result', () => { + const r = runScenario({ + printMode: true, + stdinInherit: false, + scenario: 'stream-json-requested-exit-then-result', + extraArgs: ['--output-format=stream-json'], + }); + try { + assert.equal(r.status, 0, `expected status 0, got ${r.status}; stderr=${r.stderr}`); + } finally { + fs.rmSync(r.tmpBase, { recursive: true, force: true }); + } +}); + +test('bootstrap branch stream-json requested exit after result', () => { + const r = runScenario({ + printMode: true, + stdinInherit: true, + scenario: 'stream-json-requested-exit-then-result', + extraArgs: ['--output-format=stream-json'], + }); + try { + assert.equal(r.status, 0, `expected status 0, got ${r.status}; stderr=${r.stderr}`); + } finally { + fs.rmSync(r.tmpBase, { recursive: true, force: true }); + } +}); + +test('installStreamJsonTerminalWatcher waits for write callback before completing result', async () => { + const helperBlock = extractBlock('cat <<\'NODE\' > "$_helper"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); + const watcherSource = extractFunction(helperBlock, 'function installStreamJsonTerminalWatcher() {', '\n function forceTimeoutExit'); + + // Create a dedicated mock stdout object instead of modifying the real one + let callbackFired = false; + const mockStdout = Object.create(Object.getPrototypeOf(process.stdout)); + + // Copy necessary properties + Object.defineProperty(mockStdout, 'write', { + value: function(chunk, encoding, callback) { + if (typeof encoding === 'function') { + callback = encoding; + encoding = undefined; + } + if (callback) { + // Defer callback to next microtask + setImmediate(() => { + callbackFired = true; + callback(); + }); + } + return true; + }, + writable: true, + configurable: true, + }); + + const context = vm.createContext({ + module: { exports: {} }, + process: { stdout: mockStdout }, + Object, + Buffer, + require: (id) => { + if (id === 'string_decoder') return require('string_decoder'); + throw new Error('require not available'); + }, + }); + + vm.runInContext(` + ${watcherSource} + module.exports = installStreamJsonTerminalWatcher; + `, context); + + const installStreamJsonTerminalWatcher = context.module.exports; + const watcher = installStreamJsonTerminalWatcher(); + + // Simulate a write with result JSON + const resultJson = '{"type":"result","data":"test"}\n'; + mockStdout.write(resultJson, 'utf8'); + + // Get the promise before callback fires + const resultPromise = watcher.waitForResult(); + + // Give time for callback to fire + await new Promise(resolve => setTimeout(resolve, 50)); + + // Promise should now be resolved + await resultPromise; + assert.ok(callbackFired, 'callback should have been fired'); + + watcher.restore(); }); From 90bc7b7a2b3b56de195c4a0fae6a24c9fdcb63f1 Mon Sep 17 00:00:00 2001 From: Vash0001 Date: Thu, 30 Jul 2026 19:42:42 +0900 Subject: [PATCH 2/2] fix: clear residual print-result timeout to prevent stream-json hang The .unref() removal (previous commit) fixed the safety-net timeout not firing, but left the timer uncleared after result detection, causing every successful stream-json run to hang until the timeout fires (up to 5 minutes by default). Add clearTimeout in both helper and bootstrap finally blocks, plus regression tests. Also bump version to 2.1.220-3 (2.1.220-2 is already published to npm). Co-Authored-By: Claude Haiku 4.5 --- config/claude-native-audited-versions.json | 9 ++ config/claude-termux-release-manifest.json | 2 +- .../claude-native-audited-versions.json | 9 ++ .../claude-termux-release-manifest.json | 2 +- .../lib/termux-run-claude-native.sh | 18 +++- .../lib/termux-run-claude-native.test.js | 92 +++++++++++++++++++ packages/claude-code/package.json | 2 +- 7 files changed, 127 insertions(+), 7 deletions(-) diff --git a/config/claude-native-audited-versions.json b/config/claude-native-audited-versions.json index a4d3cbb..4333045 100644 --- a/config/claude-native-audited-versions.json +++ b/config/claude-native-audited-versions.json @@ -599,6 +599,15 @@ "tarball_integrity": "sha512-VHFI8mKruIntKn7eq81sbyS19/KWmQcmJQsS/C+j9M/E+w0s4UytgsL7DADPjBE/GByNiKoRtLYDMntCjRlOdA==", "tarball_sha256": "e38454d73576a08a2e707f26539d73fc9ef33e890228ca5c58a2bbe810ac884d", "status": "termux_verified" + }, + "2.1.220-3": { + "wrapper_spec": "@anthropic-ai/claude-code@2.1.220", + "native_spec": "@anthropic-ai/claude-code-linux-arm64@2.1.220", + "entry_js_offset": 243831156, + "entry_end_offset": 265457701, + "tarball_integrity": "sha512-VHFI8mKruIntKn7eq81sbyS19/KWmQcmJQsS/C+j9M/E+w0s4UytgsL7DADPjBE/GByNiKoRtLYDMntCjRlOdA==", + "tarball_sha256": "e38454d73576a08a2e707f26539d73fc9ef33e890228ca5c58a2bbe810ac884d", + "status": "offset_discovered" } } } diff --git a/config/claude-termux-release-manifest.json b/config/claude-termux-release-manifest.json index 8b6d35a..cba2930 100644 --- a/config/claude-termux-release-manifest.json +++ b/config/claude-termux-release-manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "package_name": "@bash0816/claude-code", "latest_audited_version": "2.1.220-2", - "latest_candidate_version": "2.1.220-2", + "latest_candidate_version": "2.1.220-3", "previous_stable_version": "2.1.220", "stable_pinned_version": "2.1.193", "manifest_url": "https://raw.githubusercontent.com/bash0816/ClaudeCode-Termux/main/config/claude-termux-release-manifest.json" diff --git a/packages/claude-code/config/claude-native-audited-versions.json b/packages/claude-code/config/claude-native-audited-versions.json index a4d3cbb..4333045 100644 --- a/packages/claude-code/config/claude-native-audited-versions.json +++ b/packages/claude-code/config/claude-native-audited-versions.json @@ -599,6 +599,15 @@ "tarball_integrity": "sha512-VHFI8mKruIntKn7eq81sbyS19/KWmQcmJQsS/C+j9M/E+w0s4UytgsL7DADPjBE/GByNiKoRtLYDMntCjRlOdA==", "tarball_sha256": "e38454d73576a08a2e707f26539d73fc9ef33e890228ca5c58a2bbe810ac884d", "status": "termux_verified" + }, + "2.1.220-3": { + "wrapper_spec": "@anthropic-ai/claude-code@2.1.220", + "native_spec": "@anthropic-ai/claude-code-linux-arm64@2.1.220", + "entry_js_offset": 243831156, + "entry_end_offset": 265457701, + "tarball_integrity": "sha512-VHFI8mKruIntKn7eq81sbyS19/KWmQcmJQsS/C+j9M/E+w0s4UytgsL7DADPjBE/GByNiKoRtLYDMntCjRlOdA==", + "tarball_sha256": "e38454d73576a08a2e707f26539d73fc9ef33e890228ca5c58a2bbe810ac884d", + "status": "offset_discovered" } } } diff --git a/packages/claude-code/config/claude-termux-release-manifest.json b/packages/claude-code/config/claude-termux-release-manifest.json index 8b6d35a..cba2930 100644 --- a/packages/claude-code/config/claude-termux-release-manifest.json +++ b/packages/claude-code/config/claude-termux-release-manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "package_name": "@bash0816/claude-code", "latest_audited_version": "2.1.220-2", - "latest_candidate_version": "2.1.220-2", + "latest_candidate_version": "2.1.220-3", "previous_stable_version": "2.1.220", "stable_pinned_version": "2.1.193", "manifest_url": "https://raw.githubusercontent.com/bash0816/ClaudeCode-Termux/main/config/claude-termux-release-manifest.json" diff --git a/packages/claude-code/lib/termux-run-claude-native.sh b/packages/claude-code/lib/termux-run-claude-native.sh index 959b946..5a7996a 100755 --- a/packages/claude-code/lib/termux-run-claude-native.sh +++ b/packages/claude-code/lib/termux-run-claude-native.sh @@ -729,10 +729,15 @@ async function main() { let timedOut = false; const rawResultTimeoutMs = Number(process.env.CLAUDE_TERMUX_PRINT_RESULT_TIMEOUT_MS); const resultTimeoutMs = (Number.isFinite(rawResultTimeoutMs) && rawResultTimeoutMs > 0) ? rawResultTimeoutMs : 300000; + let timeoutHandle; const timeoutPromise = new Promise(resolve => { - setTimeout(() => { timedOut = true; resolve(); }, resultTimeoutMs); + timeoutHandle = setTimeout(() => { timedOut = true; resolve(); }, resultTimeoutMs); }); - await Promise.race([streamJsonWatcher.waitForResult(), timeoutPromise]); + try { + await Promise.race([streamJsonWatcher.waitForResult(), timeoutPromise]); + } finally { + clearTimeout(timeoutHandle); + } if (timedOut) { forceTimeoutExit(1); return; @@ -1544,10 +1549,15 @@ async function main() { let timedOut = false; const rawResultTimeoutMs = Number(process.env.CLAUDE_TERMUX_PRINT_RESULT_TIMEOUT_MS); const resultTimeoutMs = (Number.isFinite(rawResultTimeoutMs) && rawResultTimeoutMs > 0) ? rawResultTimeoutMs : 300000; + let timeoutHandle; const timeoutPromise = new Promise(resolve => { - setTimeout(() => { timedOut = true; resolve(); }, resultTimeoutMs); + timeoutHandle = setTimeout(() => { timedOut = true; resolve(); }, resultTimeoutMs); }); - await Promise.race([streamJsonWatcher.waitForResult(), timeoutPromise]); + try { + await Promise.race([streamJsonWatcher.waitForResult(), timeoutPromise]); + } finally { + clearTimeout(timeoutHandle); + } if (timedOut) { forceTimeoutExit(1); return; 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 d96d6d1..fbba7ce 100644 --- a/packages/claude-code/lib/termux-run-claude-native.test.js +++ b/packages/claude-code/lib/termux-run-claude-native.test.js @@ -894,6 +894,98 @@ test('CLAUDE_TERMUX_PRINT_RESULT_TIMEOUT_MS fallback to 300000 on zero', () => { } }); +test('helper branch (CLI -p, no stdin inherit) stream-json: result detected -> exits immediately without waiting for timeout', () => { + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-stream-json-clear-timeout-')); + try { + 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', + CLAUDE_TERMUX_PRINT_RESULT_TIMEOUT_MS: '10000', + TMPDIR: tmpBase, + TEST_SCENARIO: 'stream-json-result', + }; + delete env.CLAUDE_TERMUX_STDIN; + + const start = Date.now(); + const result = child_process.spawnSync('sh', [scriptPath, '-p', 'x', '--output-format=stream-json'], { + env, + input: 'test input\n', + encoding: 'utf8', + timeout: 20000, + }); + const elapsedMs = Date.now() - start; + + assert.equal(result.status, 0, `expected successful exit, got status=${result.status} stderr=${result.stderr}`); + assert.ok( + elapsedMs < 3000, + `expected process to exit quickly after result detected, but elapsed=${elapsedMs}ms (should be < 3000ms with 10000ms timeout). This indicates the timeout timer was not cleared.` + ); + } finally { + fs.rmSync(tmpBase, { recursive: true, force: true }); + } +}); + +test('bootstrap branch (-p + CLAUDE_TERMUX_STDIN=inherit) stream-json: result detected -> exits immediately without waiting for timeout', () => { + const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-stream-json-clear-timeout-bootstrap-')); + try { + 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_STDIN: 'inherit', + CLAUDE_TERMUX_PRINT_WAIT_MS: '300', + CLAUDE_TERMUX_PRINT_RESULT_TIMEOUT_MS: '10000', + TMPDIR: tmpBase, + TEST_SCENARIO: 'stream-json-result', + }; + + const start = Date.now(); + const result = child_process.spawnSync('sh', [scriptPath, '-p', 'x', '--output-format=stream-json'], { + env, + input: 'test input\n', + encoding: 'utf8', + timeout: 20000, + }); + const elapsedMs = Date.now() - start; + + assert.equal(result.status, 0, `expected successful exit, got status=${result.status} stderr=${result.stderr}`); + assert.ok( + elapsedMs < 3000, + `expected process to exit quickly after result detected, but elapsed=${elapsedMs}ms (should be < 3000ms with 10000ms timeout). This indicates the timeout timer was not cleared.` + ); + } finally { + fs.rmSync(tmpBase, { recursive: true, force: true }); + } +}); + test('helper and bootstrap installStreamJsonTerminalWatcher helpers stay identical', () => { const helperBlock = extractBlock('cat <<\'NODE\' > "$_helper"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); const bootstrapBlock = extractBlock('cat <<\'NODE\' > "$_bootstrap"', '\n export ENABLE_CLAUDEAI_MCP_SERVERS='); diff --git a/packages/claude-code/package.json b/packages/claude-code/package.json index c5420cf..894d3ac 100644 --- a/packages/claude-code/package.json +++ b/packages/claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@bash0816/claude-code", - "version": "2.1.220-2", + "version": "2.1.220-3", "description": "Unofficial Termux-native Claude Code wrapper with audited native replay", "license": "GPL-3.0-only", "bin": {