Skip to content

Commit 2f46b08

Browse files
fix(cli): keep terminal reporting failures nonfatal (#412)
1 parent 41108ca commit 2f46b08

4 files changed

Lines changed: 425 additions & 30 deletions

File tree

sdk/typescript/src/cli.ts

Lines changed: 105 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ const SHOW_CURSOR = "\u001B[?25h";
128128
const CHILD_TERMINATION_GRACE_MS = 1_000;
129129

130130
type Writable = Pick<NodeJS.WriteStream, "write"> & {
131+
on?(event: "error", listener: (error: Error) => void): unknown;
132+
off?(event: "error", listener: (error: Error) => void): unknown;
131133
readonly isTTY?: boolean;
132134
readonly fd?: number;
133135
readonly columns?: number;
@@ -2646,6 +2648,39 @@ async function runScan(
26462648
errorOutput: Writable,
26472649
dependencies: CliDependencies,
26482650
interactive = true,
2651+
): Promise<ScanOutcome> {
2652+
const observeTerminalErrors =
2653+
typeof errorOutput.on === "function" &&
2654+
typeof errorOutput.off === "function";
2655+
const ignoreTerminalError = (): void => {};
2656+
if (observeTerminalErrors) {
2657+
errorOutput.on?.("error", ignoreTerminalError);
2658+
}
2659+
try {
2660+
return await executeScan(
2661+
arguments_,
2662+
errorOutput,
2663+
dependencies,
2664+
interactive,
2665+
);
2666+
} finally {
2667+
if (observeTerminalErrors) {
2668+
try {
2669+
errorOutput.write("", () => {
2670+
queueMicrotask(() => errorOutput.off?.("error", ignoreTerminalError));
2671+
});
2672+
} catch {
2673+
errorOutput.off?.("error", ignoreTerminalError);
2674+
}
2675+
}
2676+
}
2677+
}
2678+
2679+
async function executeScan(
2680+
arguments_: ScanArguments,
2681+
errorOutput: Writable,
2682+
dependencies: CliDependencies,
2683+
interactive = true,
26492684
): Promise<ScanOutcome> {
26502685
let scanDir: string | null = null;
26512686
let requestedSignal: SignalName | null = null;
@@ -2691,6 +2726,14 @@ async function runScan(
26912726
});
26922727
};
26932728
const preparationAbortController = new AbortController();
2729+
const stopPresentation = (): void => {
2730+
try {
2731+
dashboard?.stop();
2732+
} catch {}
2733+
try {
2734+
progress?.stopTimer();
2735+
} catch {}
2736+
};
26942737
const signalListener = (signal: SignalName) => () => {
26952738
if (requestedSignal !== null) {
26962739
// Launchers and terminals can deliver the same initial signal twice.
@@ -2702,8 +2745,7 @@ async function runScan(
27022745
return;
27032746
}
27042747
requestedSignal = signal;
2705-
dashboard?.stop();
2706-
progress?.stopTimer();
2748+
stopPresentation();
27072749
if (progress?.interactive === true) {
27082750
try {
27092751
dependencies.writeSynchronously(errorOutput, SHOW_CURSOR);
@@ -3150,8 +3192,7 @@ async function runScan(
31503192
failed = true;
31513193
failure = error;
31523194
} finally {
3153-
dashboard?.stop();
3154-
progress?.stopTimer();
3195+
stopPresentation();
31553196
if (security !== null) {
31563197
diagnostic("runtime.cleanup.started");
31573198
await security.close().then(
@@ -3229,6 +3270,7 @@ async function runScan(
32293270
: undefined,
32303271
verified: effectivePreflight.authentication.verified,
32313272
});
3273+
progress?.stopTimer();
32323274
return { exitCode: 0, data: { dryRun: true, ...effectivePreflight } };
32333275
}
32343276
if (result === null) {
@@ -3277,6 +3319,7 @@ async function runScan(
32773319
errorOutput.write(
32783320
"codex-security: Scan target changed during execution; results do not represent the current checkout.\n",
32793321
);
3322+
progress?.stopTimer();
32803323
return { exitCode: 2, data: scanData };
32813324
}
32823325
if (incomplete) {
@@ -3285,8 +3328,10 @@ async function runScan(
32853328
? `codex-security: Scan coverage is ${result.coverage.completeness}; results may be incomplete.\n`
32863329
: `codex-security: Cannot evaluate the failure policy: coverage is ${result.coverage.completeness}.\n`,
32873330
);
3331+
progress?.stopTimer();
32883332
return { exitCode: 2, data: scanData };
32893333
}
3334+
progress?.stopTimer();
32903335
return { exitCode: blockingCount > 0 ? 1 : 0, data: scanData };
32913336
}
32923337

@@ -3713,6 +3758,10 @@ export class Progress {
37133758
#timerMessage: string | null = null;
37143759
#timerLineActive = false;
37153760
#cursorHidden = false;
3761+
#observingStreamErrors = false;
3762+
#streamErrorsActive = false;
3763+
#streamErrorGeneration = 0;
3764+
readonly #onStreamError = (): void => {};
37163765
37173766
public constructor(
37183767
stream: Writable = process.stderr,
@@ -3740,37 +3789,64 @@ export class Progress {
37403789
}
37413790
37423791
public stage(message: string): void {
3792+
this.#observeStreamErrors();
37433793
this.#stream.write(`${this.#line(message)}\n`);
37443794
}
37453795
37463796
public startTimer(message: string): void {
3797+
this.#observeStreamErrors();
37473798
if (!this.interactive) {
37483799
this.stage(message);
37493800
return;
37503801
}
37513802
this.#stream.write(HIDE_CURSOR);
37523803
this.#cursorHidden = true;
37533804
this.#renderTimer(message);
3754-
this.#timer = this.#dependencies.setInterval(
3755-
() => this.#renderTimer(message),
3756-
PROGRESS_REFRESH_MILLISECONDS,
3757-
);
3805+
this.#timer = this.#dependencies.setInterval(() => {
3806+
try {
3807+
this.#renderTimer(message);
3808+
} catch {}
3809+
}, PROGRESS_REFRESH_MILLISECONDS);
37583810
this.#timerMessage = message;
37593811
}
37603812
37613813
public stopTimer(): void {
3762-
if (this.#timer !== null) {
3763-
this.#dependencies.clearInterval(this.#timer);
3764-
this.#timer = null;
3765-
}
3766-
this.#timerMessage = null;
3767-
if (this.#timerLineActive) {
3768-
this.#stream.write("\n");
3769-
this.#timerLineActive = false;
3770-
}
3771-
if (this.#cursorHidden) {
3772-
this.#stream.write(SHOW_CURSOR);
3773-
this.#cursorHidden = false;
3814+
try {
3815+
if (this.#timer !== null) {
3816+
this.#dependencies.clearInterval(this.#timer);
3817+
this.#timer = null;
3818+
}
3819+
this.#timerMessage = null;
3820+
if (this.#timerLineActive) {
3821+
this.#stream.write("\n");
3822+
this.#timerLineActive = false;
3823+
}
3824+
if (this.#cursorHidden) {
3825+
this.#stream.write(SHOW_CURSOR);
3826+
this.#cursorHidden = false;
3827+
}
3828+
} finally {
3829+
if (this.#observingStreamErrors) {
3830+
this.#streamErrorsActive = false;
3831+
const generation = this.#streamErrorGeneration;
3832+
try {
3833+
this.#stream.write("", () => {
3834+
queueMicrotask(() => {
3835+
if (
3836+
generation === this.#streamErrorGeneration &&
3837+
!this.#streamErrorsActive &&
3838+
this.#observingStreamErrors
3839+
) {
3840+
this.#stream.off?.("error", this.#onStreamError);
3841+
this.#observingStreamErrors = false;
3842+
}
3843+
});
3844+
});
3845+
} catch {
3846+
this.#stream.off?.("error", this.#onStreamError);
3847+
this.#observingStreamErrors = false;
3848+
}
3849+
}
37743850
}
37753851
}
37763852
@@ -3795,6 +3871,15 @@ export class Progress {
37953871
return `[${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}] ${message}`;
37963872
}
37973873
3874+
#observeStreamErrors(): void {
3875+
this.#streamErrorsActive = true;
3876+
this.#streamErrorGeneration += 1;
3877+
if (!this.#observingStreamErrors && this.#stream.on !== undefined) {
3878+
this.#stream.on("error", this.#onStreamError);
3879+
this.#observingStreamErrors = true;
3880+
}
3881+
}
3882+
37983883
#renderTimer(message: string): void {
37993884
this.#stream.write(
38003885
`${this.#timerLineActive ? "\r" : ""}${this.#line(message)}`,

sdk/typescript/src/scan-dashboard.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ const MAX_HISTORY_ENTRIES = 2_000;
1818
const FIXED_SCREEN_ROWS = 8;
1919

2020
interface DashboardStream {
21-
write(chunk: string): unknown;
21+
write(chunk: string, callback?: (error?: Error | null) => void): unknown;
22+
on?(event: "error", listener: (error: Error) => void): unknown;
23+
off?(event: "error", listener: (error: Error) => void): unknown;
2224
readonly columns?: number;
2325
readonly rows?: number;
2426
}
@@ -101,6 +103,8 @@ export class ScanDashboard {
101103
#scrollOffset = 0;
102104
#inputWasRaw = false;
103105
#noteCount = 0;
106+
#observingStreamErrors = false;
107+
readonly #onStreamError = (): void => {};
104108
readonly #onInput = (chunk: string | Uint8Array): void => {
105109
const input =
106110
typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
@@ -149,8 +153,12 @@ export class ScanDashboard {
149153
if (input?.isTTY === true) {
150154
this.#inputWasRaw = input.isRaw === true;
151155
}
152-
this.#timer = this.#options.clock.setInterval(() => this.#render(), 1_000);
156+
this.#timer = this.#options.clock.setInterval(() => this.#refresh(), 1_000);
153157
try {
158+
if (!this.#observingStreamErrors && this.#stream.on !== undefined) {
159+
this.#stream.on("error", this.#onStreamError);
160+
this.#observingStreamErrors = true;
161+
}
154162
this.#stream.write(`${ENTER_ALTERNATE_SCREEN}${HIDE_CURSOR}`);
155163
if (input?.isTTY === true) {
156164
input.setRawMode?.(true);
@@ -178,14 +186,32 @@ export class ScanDashboard {
178186
this.#options.clock.clearInterval(this.#timer);
179187
this.#timer = null;
180188
const input = this.#options.input;
181-
if (input?.isTTY === true) {
182-
input.off("data", this.#onInput);
183-
input.setRawMode?.(this.#inputWasRaw);
184-
input.pause?.();
189+
try {
190+
if (input?.isTTY === true) {
191+
input.off("data", this.#onInput);
192+
input.setRawMode?.(this.#inputWasRaw);
193+
input.pause?.();
194+
}
195+
this.#stream.write(
196+
`${input?.isTTY === true ? DISABLE_ALTERNATE_SCROLL : ""}${SHOW_CURSOR}${EXIT_ALTERNATE_SCREEN}`,
197+
);
198+
} finally {
199+
if (this.#observingStreamErrors) {
200+
try {
201+
this.#stream.write("", () => {
202+
queueMicrotask(() => {
203+
if (this.#timer === null && this.#observingStreamErrors) {
204+
this.#stream.off?.("error", this.#onStreamError);
205+
this.#observingStreamErrors = false;
206+
}
207+
});
208+
});
209+
} catch {
210+
this.#stream.off?.("error", this.#onStreamError);
211+
this.#observingStreamErrors = false;
212+
}
213+
}
185214
}
186-
this.#stream.write(
187-
`${input?.isTTY === true ? DISABLE_ALTERNATE_SCROLL : ""}${SHOW_CURSOR}${EXIT_ALTERNATE_SCREEN}`,
188-
);
189215
}
190216

191217
public setStage(stage: string): void {
@@ -258,7 +284,10 @@ export class ScanDashboard {
258284
}
259285

260286
#refresh(): void {
261-
if (this.#timer !== null) this.#render();
287+
if (this.#timer === null) return;
288+
try {
289+
this.#render();
290+
} catch {}
262291
}
263292

264293
#render(): void {

0 commit comments

Comments
 (0)