From e0bda7a17111753f76b7e073d654be802c06c129 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 16 Jul 2026 17:56:55 -0400 Subject: [PATCH] fix(cli): keep post-render exit reset root-owned --- packages/cli/src/cli.ts | 7 +++++++ packages/cli/src/utils/commandResult.ts | 11 +++++++++++ .../cli/src/utils/render-success-state.test.ts | 15 ++++++++++++++- packages/cli/src/utils/render-success-state.ts | 14 +++++++------- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 602ad331c..609b76037 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -110,6 +110,7 @@ import { CliRuntimeError, CliUsageError, consumeCommandResult, + registerRootExitCodeSanitizer, registerRootExitRequester, type CommandResult, } from "./utils/commandResult.js"; @@ -303,6 +304,12 @@ registerRootExitRequester((exitCode) => { }).finally(() => process.exit(exitCode)); }); +registerRootExitCodeSanitizer(() => { + if (process.exitCode !== undefined && process.exitCode !== 0) { + process.exitCode = 0; + } +}); + // Sync-only: exit handlers cannot await promises or drain microtasks. // _trackCommandResult / _trackCliError are captured references resolved // at init time, so they're callable synchronously here. diff --git a/packages/cli/src/utils/commandResult.ts b/packages/cli/src/utils/commandResult.ts index 4ba980f33..8aa8ca47d 100644 --- a/packages/cli/src/utils/commandResult.ts +++ b/packages/cli/src/utils/commandResult.ts @@ -10,6 +10,7 @@ export interface CommandResult { const SUCCESS_RESULT: CommandResult = { exitCode: 0, kind: "success" }; let pendingResult: CommandResult = SUCCESS_RESULT; let rootExitRequester: ((exitCode: number) => void) | undefined; +let rootExitCodeSanitizer: (() => void) | undefined; export class CliUsageError extends Error { readonly result: CommandResult; @@ -97,6 +98,16 @@ export function registerRootExitRequester(requester: (exitCode: number) => void) rootExitRequester = requester; } +/** Called only by cli.ts to retain ownership of process exit-code mutation. */ +export function registerRootExitCodeSanitizer(sanitizer: () => void): void { + rootExitCodeSanitizer = sanitizer; +} + +/** Ask cli.ts to clear stray process exit state after a successful render. */ +export function sanitizeSuccessfulExitCode(): void { + rootExitCodeSanitizer?.(); +} + /** Ask cli.ts to finalize telemetry/output and then terminate the process. */ export function requestCliExit(exitCode = 0): void { if (!rootExitRequester) { diff --git a/packages/cli/src/utils/render-success-state.test.ts b/packages/cli/src/utils/render-success-state.test.ts index 4a148de2f..95c084c42 100644 --- a/packages/cli/src/utils/render-success-state.test.ts +++ b/packages/cli/src/utils/render-success-state.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerRootExitCodeSanitizer } from "./commandResult.js"; import { _resetRenderSuccessForTests, isRenderSucceeded, @@ -38,6 +39,12 @@ describe("render-success-state flag", () => { describe("runPostRenderStep", () => { const originalExitCode = process.exitCode; + beforeEach(() => { + registerRootExitCodeSanitizer(() => { + process.exitCode = 0; + }); + }); + afterEach(() => { process.exitCode = originalExitCode; _resetRenderSuccessForTests(); @@ -118,6 +125,12 @@ describe("runPostRenderStep", () => { describe("runPostRenderStepAsync", () => { const originalExitCode = process.exitCode; + beforeEach(() => { + registerRootExitCodeSanitizer(() => { + process.exitCode = 0; + }); + }); + afterEach(() => { process.exitCode = originalExitCode; _resetRenderSuccessForTests(); diff --git a/packages/cli/src/utils/render-success-state.ts b/packages/cli/src/utils/render-success-state.ts index d69c8cb95..f4deec971 100644 --- a/packages/cli/src/utils/render-success-state.ts +++ b/packages/cli/src/utils/render-success-state.ts @@ -22,6 +22,8 @@ * still exited 1 after the terminal "artifact validated" checkpoint. */ +import { sanitizeSuccessfulExitCode } from "./commandResult.js"; + let renderSucceeded = false; /** @@ -57,8 +59,8 @@ const defaultErrorSink: PostRenderErrorSink = (message) => { * Run a post-artifact-validated cleanup step so a throw cannot flip the CLI * exit code. `markRenderSucceeded()` MUST have been called first — this * helper is only safe on the success path where the artifact is already - * committed to disk. Logs a compact warning to stderr, resets a stray - * `process.exitCode` back to 0, and swallows the error. + * committed to disk. Logs a compact warning to stderr, asks the root CLI to + * clear a stray exit code, and swallows the error. */ export function runPostRenderStep( label: string, @@ -87,9 +89,7 @@ export async function runPostRenderStepAsync( function reportPostRenderStepFailure(label: string, err: unknown, sink: PostRenderErrorSink): void { const message = err instanceof Error ? err.message : String(err); sink(` [hyperframes] Post-render step '${label}' failed (render already succeeded): ${message}`); - // Guard against the failing step (or something it triggered) setting a - // non-zero exitCode. The render succeeded → the CLI must exit 0. - if (process.exitCode !== undefined && process.exitCode !== 0) { - process.exitCode = 0; - } + // The failing step (or something it triggered) may have set a non-zero + // exitCode. The render succeeded, so ask the root CLI owner to clear it. + sanitizeSuccessfulExitCode(); }