From 4fb536caa919fe8a2a7673a6c5a1c35009b0c502 Mon Sep 17 00:00:00 2001 From: Filipe Brito Date: Tue, 11 Aug 2026 11:59:38 -0500 Subject: [PATCH 1/2] fix: bound stdin read so the process cannot outlive its render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readStdin() iterated process.stdin until EOF. A host that writes the status JSON but keeps the write end open leaves that iteration suspended forever: the line renders, but the process stays resident. With a short refresh interval these accumulate. Race the read against a timeout (5s default, CCSTATUSLINE_STDIN_TIMEOUT_MS to override) and resolve with whatever already arrived — the payload is written in one shot, so a missing EOF does not mean missing data and the status line still renders. Clear the timer and pause/unref the stream on the way out. readStdin moves to src/utils/stdin.ts because the entrypoint runs main() on import, which makes the function untestable in place. --- src/ccstatusline.ts | 30 +------------- src/utils/__tests__/stdin.test.ts | 66 ++++++++++++++++++++++++++++++ src/utils/stdin.ts | 67 +++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 29 deletions(-) create mode 100644 src/utils/__tests__/stdin.test.ts create mode 100644 src/utils/stdin.ts 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..3cc5d809 --- /dev/null +++ b/src/utils/__tests__/stdin.test.ts @@ -0,0 +1,66 @@ +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..a915ea23 --- /dev/null +++ b/src/utils/stdin.ts @@ -0,0 +1,67 @@ +/** + * 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 => { + // 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(''); + }; + + 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(); + process.stdin.unref?.(); + } +} From 70c8dffd911f8663aa2b7ef9037930ef35170f0d Mon Sep 17 00:00:00 2001 From: Filipe Brito Date: Sun, 23 Aug 2026 21:52:17 -0300 Subject: [PATCH 2/2] fix: use the Node-compatible stdin stream --- src/utils/__tests__/stdin.test.ts | 1 - src/utils/stdin.ts | 28 +++++++++++++--------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/utils/__tests__/stdin.test.ts b/src/utils/__tests__/stdin.test.ts index 3cc5d809..e2e1b509 100644 --- a/src/utils/__tests__/stdin.test.ts +++ b/src/utils/__tests__/stdin.test.ts @@ -1,5 +1,4 @@ import { Readable } from 'node:stream'; - import { afterEach, beforeEach, diff --git a/src/utils/stdin.ts b/src/utils/stdin.ts index a915ea23..fec96b45 100644 --- a/src/utils/stdin.ts +++ b/src/utils/stdin.ts @@ -27,19 +27,12 @@ export async function readStdin(): Promise { const chunks: string[] = []; const read = async (): Promise => { - // 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); - } + // 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(''); }; @@ -50,7 +43,9 @@ export async function readStdin(): Promise { return await Promise.race([ read(), new Promise((resolve) => { - timer = setTimeout(() => resolve(chunks.join('')), getStdinTimeoutMs()); + timer = setTimeout(() => { + resolve(chunks.join('')); + }, getStdinTimeoutMs()); }) ]); } catch { @@ -62,6 +57,9 @@ export async function readStdin(): Promise { // The reader can still hold the stream open after the race settles. process.stdin.pause(); - process.stdin.unref?.(); + const unref = Reflect.get(process.stdin, 'unref') as unknown; + if (typeof unref === 'function') { + unref.call(process.stdin); + } } }