diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index be492891..09438f98 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -46,6 +46,7 @@ import { getWidgetSpeedWindowSeconds, isWidgetSpeedWindowEnabled } from './utils/speed-window'; +import { readStdin } from './utils/stdin'; import { getPackageVersion, getTerminalWidth @@ -57,35 +58,6 @@ function hasSessionDurationInStatusJson(data: StatusJSON): boolean { return typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0; } -async function readStdin(): Promise { - // Check if stdin is a TTY (terminal) - if it is, there's no piped data - if (process.stdin.isTTY) { - return null; - } - - const chunks: string[] = []; - - try { - // Use Node.js compatible approach - if (typeof Bun !== 'undefined') { - // Bun environment - const decoder = new TextDecoder(); - for await (const chunk of Bun.stdin.stream()) { - chunks.push(decoder.decode(chunk)); - } - } else { - // Node.js environment - process.stdin.setEncoding('utf8'); - for await (const chunk of process.stdin) { - chunks.push(chunk as string); - } - } - return chunks.join(''); - } catch { - return null; - } -} - async function ensureWindowsUtf8CodePage() { if (process.platform !== 'win32') { return; diff --git a/src/utils/__tests__/stdin.test.ts b/src/utils/__tests__/stdin.test.ts new file mode 100644 index 00000000..e2e1b509 --- /dev/null +++ b/src/utils/__tests__/stdin.test.ts @@ -0,0 +1,65 @@ +import { Readable } from 'node:stream'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { readStdin } from '../stdin'; + +const originalStdin = process.stdin; +const originalTimeout = process.env.CCSTATUSLINE_STDIN_TIMEOUT_MS; + +function setStdin(stream: Readable & { isTTY?: boolean }) { + Object.defineProperty(process, 'stdin', { + value: stream, + configurable: true + }); +} + +describe('readStdin', () => { + beforeEach(() => { + process.env.CCSTATUSLINE_STDIN_TIMEOUT_MS = '50'; + }); + + afterEach(() => { + Object.defineProperty(process, 'stdin', { + value: originalStdin, + configurable: true + }); + + if (originalTimeout === undefined) { + delete process.env.CCSTATUSLINE_STDIN_TIMEOUT_MS; + } else { + process.env.CCSTATUSLINE_STDIN_TIMEOUT_MS = originalTimeout; + } + + vi.useRealTimers(); + }); + + it('returns null when stdin is a TTY', async () => { + const stream = Object.assign(Readable.from([]), { isTTY: true }); + setStdin(stream); + + await expect(readStdin()).resolves.toBeNull(); + }); + + it('reads the payload when the writer closes the stream', async () => { + const stream = Object.assign(Readable.from(['{"session_id":"abc"}']), { isTTY: false }); + setStdin(stream); + + await expect(readStdin()).resolves.toBe('{"session_id":"abc"}'); + }); + + it('resolves with what arrived when EOF never comes', async () => { + const stream = Object.assign(new Readable({ read() { /* never pushes EOF */ } }), { isTTY: false }); + stream.push('{"session_id":"abc"}'); + setStdin(stream); + + // Without the timeout this would hang and the process would outlive the render. + await expect(readStdin()).resolves.toBe('{"session_id":"abc"}'); + }); +}); diff --git a/src/utils/stdin.ts b/src/utils/stdin.ts new file mode 100644 index 00000000..fec96b45 --- /dev/null +++ b/src/utils/stdin.ts @@ -0,0 +1,65 @@ +/** + * Default time to wait for the status JSON before giving up on EOF. + * Override with CCSTATUSLINE_STDIN_TIMEOUT_MS. + */ +const DEFAULT_STDIN_TIMEOUT_MS = 5000; + +function getStdinTimeoutMs(): number { + const raw = Number(process.env.CCSTATUSLINE_STDIN_TIMEOUT_MS); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_STDIN_TIMEOUT_MS; +} + +/** + * Reads the piped status JSON. + * + * A host that writes the payload but never closes the write end leaves the async + * iteration below suspended forever, and the process outlives the render it was + * spawned for. The timeout bounds that: whatever arrived is still worth rendering, + * because the payload is written in one shot, so a timeout means the EOF is + * missing rather than the data. + */ +export async function readStdin(): Promise { + // Check if stdin is a TTY (terminal) - if it is, there's no piped data + if (process.stdin.isTTY) { + return null; + } + + const chunks: string[] = []; + + const read = async (): Promise => { + // Bun exposes the same Node-compatible process.stdin stream. Reading + // through it keeps this function testable and avoids maintaining two + // subtly different input paths. + process.stdin.setEncoding('utf8'); + for await (const chunk of process.stdin) { + chunks.push(chunk as string); + } + return chunks.join(''); + }; + + let timer: ReturnType | undefined; + + try { + return await Promise.race([ + read(), + new Promise((resolve) => { + timer = setTimeout(() => { + resolve(chunks.join('')); + }, getStdinTimeoutMs()); + }) + ]); + } catch { + return null; + } finally { + if (timer) { + clearTimeout(timer); + } + + // The reader can still hold the stream open after the race settles. + process.stdin.pause(); + const unref = Reflect.get(process.stdin, 'unref') as unknown; + if (typeof unref === 'function') { + unref.call(process.stdin); + } + } +}