Compare commits

...
Author SHA1 Message Date
Clay GoodandClaude Opus 5 fbc3ccf171 chore(changeset): track the validate zero-width spinner fix
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:34:22 -05:00
Clay GoodandClaude Opus 5 8cdbe2d4e5 fix(validate): stop the spinner flooding a zero-width TTY
Some terminals report a TTY with 0 columns (a pty inherited from a
pre-commit runner such as lefthook, or `script -qec`). ora turns that
into an infinite lines-to-clear count, so the validate spinner cleared
forever: the command never finished and wrote hundreds of megabytes of
cursor escapes.

Skip the spinner when the stream reports no width, and decide via
isInteractive() so OPEN_SPEC_INTERACTIVE=0 and CI disable it the same
way --no-interactive already did.

Closes #2106

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 15:10:50 -05:00
4 changed files with 81 additions and 3 deletions
+13
View File
@@ -0,0 +1,13 @@
---
'@fission-ai/openspec': patch
---
Stop `openspec validate --all` hanging on a zero-width TTY. Some hosts hand the
process a terminal that reports `columns === 0` — a pty inherited from a
pre-commit runner such as lefthook, or `script -qec`. ora computes how many
lines to erase as `ceil(width / columns)`, so zero columns makes that count
`Infinity` and the clear loop never ends: the command burned a core and wrote
hundreds of megabytes of cursor escapes without finishing. The spinner is now
skipped when nothing can be drawn, and validate falls back to its plain output.
`OPEN_SPEC_INTERACTIVE=0` and `CI` now disable the spinner too, the same way
`--no-interactive` already did.
+3 -3
View File
@@ -15,7 +15,7 @@ import {
type ResolvedOpenSpecRoot,
isStoreSelectedRoot,
} from '../core/root-selection.js';
import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
import { canRenderSpinner, isInteractive, resolveNoInteractive } from '../utils/interactive.js';
import { getSpecIds } from '../utils/item-discovery.js';
import { getAvailableChanges } from './workflow/shared.js';
import { nearestMatches } from '../utils/match.js';
@@ -441,7 +441,7 @@ export class ValidateCommand {
}
private async runBulkValidation(root: ResolvedOpenSpecRoot, scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean; findingsScope?: BulkScope }): Promise<void> {
const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
const spinner = !opts.json && isInteractive(opts) && canRenderSpinner() ? ora('Validating...').start() : undefined;
const [changeIds, specIds] = await Promise.all([
scope.changes ? this.listChangeIds(root) : Promise.resolve<string[]>([]),
scope.specs ? getSpecIds(root.path) : Promise.resolve<string[]>([]),
@@ -615,7 +615,7 @@ export class ValidateCommand {
// List first (may throw on a real archive-read failure), then start the
// spinner so a thrown error never leaves a spinner spinning.
const ids = await this.listArchivedChangeIds(root);
const spinner = !opts.json && !opts.noInteractive ? ora('Validating archived changes...').start() : undefined;
const spinner = !opts.json && isInteractive(opts) && canRenderSpinner() ? ora('Validating archived changes...').start() : undefined;
// The archive is append-only and can hold thousands of changes; a single
// run resolves them all under one constant projectRoot (root.path), so
+19
View File
@@ -176,3 +176,22 @@ function readYesNo(
});
}
/**
* Whether a spinner can safely render to `stream`.
*
* ora decides how many lines to erase with `ceil(width / stream.columns)`, so a
* TTY that reports zero columns makes that count `Infinity` and `clear()` never
* ends: the spinner emits cursor-up/clear-line escapes as fast as it can and the
* command never finishes (#2106). Zero-width TTYs are real — a pty inherited
* from a pre-commit runner such as lefthook, or `script -qec` — and nothing can
* be drawn in zero columns anyway, so refuse the spinner and let the caller fall
* back to its plain output.
*/
export function canRenderSpinner(
stream: { isTTY?: boolean; columns?: number } = process.stderr
): boolean {
// A non-TTY stream never enters the clear loop: ora disables itself and
// prints one plain line instead.
if (!stream.isTTY) return true;
return (stream.columns ?? 0) > 0;
}
+46
View File
@@ -5,6 +5,7 @@ import {
isInteractive,
isNonInteractivePromptError,
resolveNoInteractive,
canRenderSpinner,
InteractiveOptions,
} from '../../src/utils/interactive.js';
@@ -382,4 +383,49 @@ describe('interactive utilities', () => {
).rejects.toThrow('stdin exploded');
});
});
describe('canRenderSpinner', () => {
it('refuses a TTY that reports zero columns', () => {
expect(canRenderSpinner({ isTTY: true, columns: 0 })).toBe(false);
});
it('refuses a TTY that reports no width at all', () => {
expect(canRenderSpinner({ isTTY: true })).toBe(false);
});
it('allows a TTY with a real width', () => {
expect(canRenderSpinner({ isTTY: true, columns: 80 })).toBe(true);
});
it('allows a non-TTY stream, which ora prints as one plain line', () => {
expect(canRenderSpinner({ isTTY: false })).toBe(true);
});
it('covers the case where ora clears without bound (#2106)', async () => {
// The reason the guard exists: with zero columns ora's line count is
// Infinity, so clear() keeps moving the cursor up forever. The fake stream
// is capped so the unbounded loop fails fast instead of hanging the suite.
// If a future ora bounds that loop this test goes red, and the guard can
// be revisited.
const ora = (await import('ora')).default;
let moves = 0;
const stream = {
isTTY: true,
columns: 0,
rows: 0,
write: () => true,
cursorTo: () => {},
clearLine: () => {},
moveCursor: () => {
if (++moves > 1000) throw new Error('unbounded clear');
},
once: () => {},
} as unknown as NodeJS.WriteStream;
const spinner = ora({ text: 'Validating...', stream, isEnabled: true });
spinner.render();
expect(() => spinner.render()).toThrow('unbounded clear');
expect(canRenderSpinner(stream)).toBe(false);
});
});
});