mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-17 05:49:35 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbc3ccf171 | ||
|
|
8cdbe2d4e5 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user