Files
WaterrrForever bc6dbc7e21 fix(cli): stop dropping queued telemetry when process.exit races the final flush (#2970)
* fix(cli): stop dropping queued telemetry when process.exit races the final flush

Two exit-path defects introduced by the 0.7.65 process-lifecycle refactor:

1. The 'exit' handler returned early once finalizeCli had started, which
   also skipped the flushSync() fallback. When an agent-pipe EPIPE killed
   the process mid-flush (the NORMAL teardown under Claude Code / Codex),
   the still-queued render_complete was silently dropped — fleet delivery
   fell from ~90% (0.7.55-0.7.64) to ~35%. flushSync() is now
   unconditional: empty queue is a no-op, event uuids dedupe re-sends.

2. The EPIPE handlers set commandFailed unconditionally, so every piped
   successful render scored success:false in cli_command_result (fleet
   success rate collapsed 89% -> 5-25%). EPIPE now only marks failure
   when the pipe died before the render artifact was validated, matching
   the existing isRenderSucceeded() exemption on the uncaughtException
   path.

Regression tests cover both: flushSync-after-finalize, and EPIPE
before/after artifact validation.

* fix(cli): don't score a validated render as failed due to pre-artifact noise

Review follow-up: commandFailed can be set by noise that precedes artifact
validation — a stray unhandledRejection mid-render, or an EPIPE firing
before markRenderSucceeded on a run that still completes. Once the
artifact validates, that earlier noise must not flip the run's
cli_command_result to success:false. Genuine failures keep a non-zero
exit code and are still caught by the exitCode check.

Extracted commandSucceededForTelemetry() and applied it at both tracking
sites (finalizeCli and the exit handler), with a regression test.

* test(cli): pin the production-reachable producer of the stale-failure override

Review note: the pre-artifact-noise test drives the scenario with an
EPIPE, which only reaches 'render validates afterwards' because
process.exit is mocked — that sequence can't occur in production. Add a
test for the reachable producer: an unhandledRejection before validation
(the handler deliberately does not exit), followed by a validated render,
must score success:true at exit code 0. Verified red on the pre-override
cli.ts.
2026-08-04 02:02:55 +08:00

504 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// ── EPIPE suppression (must run before ANY stdout/stderr write) ────────────
// When the CLI runs inside a piped agent environment (Claude Code, Codex,
// Cursor, etc.), the reader may close the pipe before we finish writing.
// Node treats EPIPE on stdout/stderr as an uncaughtException, which crashes
// the process. This is a normal lifecycle event — suppress it.
//
// commandFailed must be declared here (before the handlers) so the EPIPE
// stream-error path can set it before process.exit(0). The telemetry exit
// handler reads this flag to determine success/failure — an EPIPE that
// interrupts a command should NOT score as success:true, but one that
// arrives after the render artifact was validated is the normal agent-pipe
// teardown and must stay success:true (see handleStreamEpipe).
let commandFailed = false;
for (const stream of [process.stdout, process.stderr]) {
stream.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code === "EPIPE") {
handleStreamEpipe();
}
});
}
// ── Worker entry path bootstrap (must run before any producer/engine load) ──
// The shaderTransitionWorkerPool lives in the producer package and resolves
// its worker entry by probing for a sibling `.js` file next to
// `import.meta.url`. When this CLI is bundled by tsup, the producer code is
// inlined into `cli.js`, but `import.meta.url` resolves to the producer's
// own dist path (NOT cli.js) on some module-graph layouts — so the sibling
// probe lands in a directory that does not contain the bundled worker.
// We emit the worker entry next to cli.js (see tsup.config.ts) and tell
// the pool where to find it via the published env-var override.
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
(() => {
const here = dirname(fileURLToPath(import.meta.url));
const shader = join(here, "shaderTransitionWorker.js");
if (!process.env.HF_SHADER_WORKER_ENTRY && existsSync(shader)) {
process.env.HF_SHADER_WORKER_ENTRY = shader;
}
})();
// ── Fast-path exits ─────────────────────────────────────────────────────────
// Check --version before importing anything heavy. This makes
// `hyperframes --version` near-instant (~10ms vs ~80ms).
import { VERSION } from "./version.js";
const argv = process.argv.slice(2);
const commandArg = argv[0];
const rootVersionRequested =
commandArg === "--version" ||
commandArg === "-V" ||
(commandArg === undefined && (argv.includes("--version") || argv.includes("-V")));
if (rootVersionRequested) {
console.log(VERSION);
process.exit(0);
}
// ── Load .env from CWD ─────────────────────────────────────────────────────
// Agents run from the project directory where .env holds API keys (Gemini,
// HeyGen, ElevenLabs). Load it automatically so they don't need `source .env`.
try {
const { readFileSync } = await import("node:fs");
const { resolve } = await import("node:path");
const envPath = resolve(process.cwd(), ".env");
const envContent = readFileSync(envPath, "utf-8");
for (const rawLine of envContent.split("\n")) {
let line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
// Tolerate `export FOO=bar` (common in dotfile-style .env files).
if (line.startsWith("export ")) line = line.slice(7).trim();
const eqIdx = line.indexOf("=");
if (eqIdx < 1) continue;
const key = line.slice(0, eqIdx).trim();
let val = line.slice(eqIdx + 1).trim();
if (val.startsWith('"') || val.startsWith("'")) {
// Quoted value: take until the matching closing quote; leave the rest.
// Anything after a closing quote (including `# comment`) is dropped.
const quote = val.charAt(0);
const end = val.indexOf(quote, 1);
if (end > 0) val = val.slice(1, end);
else val = val.slice(1); // unterminated quote — best-effort, strip opener
} else {
// Unquoted value: strip inline `# comment` (requires whitespace before #
// to avoid eating `pass#word` style values).
const commentMatch = val.match(/\s+#/);
if (commentMatch?.index !== undefined) val = val.slice(0, commentMatch.index).trim();
}
if (key && !(key in process.env)) process.env[key] = val;
}
} catch {
/* .env not present — fine, env vars may be set another way */
}
// ── Lazy imports ────────────────────────────────────────────────────────────
// Telemetry, update checks, and heavy modules are imported only when needed.
// For --help we skip telemetry entirely.
import { defineCommand, runCommand } from "citty";
import type { ArgsDef, CommandDef } from "citty";
import { getRunId } from "./telemetry/runId.js";
import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js";
import { isRenderSucceeded } from "./utils/render-success-state.js";
import { resolveCommandUsage } from "./utils/commandUsageResolution.js";
import {
CliResultSignal,
CliRuntimeError,
CliUsageError,
consumeCommandResult,
registerRootExitCodeSanitizer,
registerRootExitRequester,
type CommandResult,
} from "./utils/commandResult.js";
const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
// ---------------------------------------------------------------------------
// CLI definition — all commands are lazy-loaded via dynamic import()
// ---------------------------------------------------------------------------
const commandLoaders = {
init: () => import("./commands/init.js").then((m) => m.default),
add: () => import("./commands/add.js").then((m) => m.default),
catalog: () => import("./commands/catalog.js").then((m) => m.default),
play: () => import("./commands/play.js").then((m) => m.default),
present: () => import("./commands/present.js").then((m) => m.default),
preview: () => import("./commands/preview.js").then((m) => m.default),
publish: () => import("./commands/publish.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
lint: () => import("./commands/lint.js").then((m) => m.default),
check: () => import("./commands/check.js").then((m) => m.default),
beats: () => import("./commands/beats.js").then((m) => m.default),
inspect: () => import("./commands/inspect.js").then((m) => m.default),
keyframes: () => import("./commands/keyframes.js").then((m) => m.default),
layout: () => import("./commands/layout.js").then((m) => m.default),
info: () => import("./commands/info.js").then((m) => m.default),
compositions: () => import("./commands/compositions.js").then((m) => m.default),
benchmark: () => import("./commands/benchmark.js").then((m) => m.default),
browser: () => import("./commands/browser.js").then((m) => m.default),
"remove-background": () => import("./commands/remove-background.js").then((m) => m.default),
transcribe: () => import("./commands/transcribe.js").then((m) => m.default),
tts: () => import("./commands/tts.js").then((m) => m.default),
docs: () => import("./commands/docs.js").then((m) => m.default),
doctor: () => import("./commands/doctor.js").then((m) => m.default),
upgrade: () => import("./commands/upgrade.js").then((m) => m.default),
skills: () => import("./commands/skills.js").then((m) => m.default),
feedback: () => import("./commands/feedback.js").then((m) => m.default),
telemetry: () => import("./commands/telemetry.js").then((m) => m.default),
events: () => import("./commands/events.js").then((m) => m.default),
validate: () => import("./commands/validate.js").then((m) => m.default),
snapshot: () => import("./commands/snapshot.js").then((m) => m.default),
"media-treatment": () =>
import("./commands/media-treatment.js").then((m) => m.mediaTreatmentCommand),
"grade-compare": () => import("./commands/grade-compare.js").then((m) => m.default),
compare: () => import("./commands/compare.js").then((m) => m.default),
capture: () => import("./commands/capture.js").then((m) => m.default),
lambda: () => import("./commands/lambda.js").then((m) => m.default),
cloudrun: () => import("./commands/cloudrun.js").then((m) => m.default),
cloud: () => import("./commands/cloud.js").then((m) => m.default),
auth: () => import("./commands/auth.js").then((m) => m.default),
figma: () => import("./commands/figma.js").then((m) => m.default),
};
const subCommands = Object.fromEntries(
Object.entries(commandLoaders).map(([name, load]) => [name, trackCommandFailures(load)]),
);
const main = defineCommand({
meta: {
name: "hyperframes",
version: VERSION,
description: "Create and render HTML video compositions",
},
subCommands,
});
// ---------------------------------------------------------------------------
// Telemetry — lazy-loaded, captured references for exit handlers
// ---------------------------------------------------------------------------
const cliCommandArg = process.argv[2];
// Explicit annotation breaks a type cycle: `subCommands` references `command`
// (in the failure reporter) and `command` references `subCommands` (the `in`
// check), so its type can't be inferred from its own initializer.
const command: string = cliCommandArg && cliCommandArg in subCommands ? cliCommandArg : "unknown";
const hasJsonFlag = process.argv.includes("--json");
// Captured references — populated when the lazy imports resolve.
// Used in exit handlers where dynamic import() is unsafe (beforeExit loops,
// exit handler is synchronous-only).
let _flush: (() => Promise<void>) | undefined;
let _flushSync: (() => void) | undefined;
let _trackCliError:
| ((props: {
error_name: string;
error_message: string;
stack_trace?: string;
command?: string;
kind: "uncaught_exception" | "unhandled_rejection" | "command_error";
}) => void)
| undefined;
let _trackCommandResult:
| ((props: {
command: string;
success: boolean;
exitCode: number;
durationMs: number;
runId?: string;
}) => void)
| undefined;
let _printUpdateNotice: (() => void) | undefined;
let _printStalePinNotice: (() => void) | undefined;
let _printSkillsUpdateNotice: (() => void) | undefined;
let telemetryReady: Promise<void> = Promise.resolve();
// `events` is a telemetry-internal beacon: it self-tracks + self-flushes, so it
// skips the per-command wrapper (no duplicate cli_command, no first-run notice
// printed into a skill's captured output).
if (!isHelp && command !== "telemetry" && command !== "events" && command !== "unknown") {
telemetryReady = import("./telemetry/index.js").then((mod) => {
_flush = mod.flush;
_flushSync = mod.flushSync;
_trackCliError = mod.trackCliError;
_trackCommandResult = mod.trackCommandResult;
mod.showTelemetryNotice();
mod.trackCommand(command, runId);
if (mod.shouldTrack()) mod.incrementCommandCount();
});
}
// `events` skips the update check too — a skill-usage beacon must not add
// network latency or trigger a background self-upgrade on the calling skill.
// `telemetry` skips it because update metadata must never race the command
// that changes the user's telemetry preference.
// `skills` is excluded from the SKILLS nudge for the same reason `upgrade` is
// excluded from the self-update notice: a command that is itself actively
// checking/reconciling skills (`skills check`, `skills update`) must not also
// tell the user to go run `skills update` — that's either redundant (it just
// did) or, worse, misleading (it printed a stale nudge count from the last
// cached check while reporting fresh results of its own).
if (
!isHelp &&
!hasJsonFlag &&
command !== "upgrade" &&
command !== "events" &&
command !== "telemetry" &&
command !== "skills"
) {
// Report any completed auto-install from the previous run first, before
// kicking off the next check — so the user sees "updated to vX" once and
// we don't over-print.
import("./utils/autoUpdate.js").then((mod) => mod.reportCompletedUpdate()).catch(() => {});
import("./utils/updateCheck.js").then(async (mod) => {
_printUpdateNotice = mod.printUpdateNotice;
_printStalePinNotice = mod.printStalePinNotice;
const result = await mod.checkForUpdate().catch(() => null);
if (result?.updateAvailable) {
const auto = await import("./utils/autoUpdate.js").catch(() => null);
auto?.scheduleBackgroundInstall(result.latest, result.current);
}
});
// Skills freshness nudge — same gating as the CLI self-update notice. The
// check is cached (24h) and best-effort: it never blocks or fails the command.
import("./utils/skillsUpdateCheck.js").then(async (mod) => {
_printSkillsUpdateNotice = mod.printSkillsUpdateNotice;
await mod.checkSkillsForUpdate().catch(() => null);
});
}
const commandStart = Date.now();
const runId = getRunId();
let finalized = false;
// Root-only lifecycle fan-in: telemetry, notices, flushing, then exit code.
// fallow-ignore-next-line complexity
async function finalizeCli(result: CommandResult): Promise<void> {
if (finalized) return;
finalized = true;
commandFailed ||= result.exitCode !== 0;
await telemetryReady.catch(() => {});
_trackCommandResult?.({
command,
success: result.exitCode === 0 && commandSucceededForTelemetry(),
exitCode: result.exitCode,
durationMs: Date.now() - commandStart,
runId,
});
await _flush?.().catch(() => {});
if (!hasJsonFlag) {
_printUpdateNotice?.();
_printStalePinNotice?.();
_printSkillsUpdateNotice?.();
}
process.exitCode = result.exitCode;
}
registerRootExitRequester((exitCode) => {
void finalizeCli({
exitCode,
kind: exitCode === 0 ? "success" : "runtime_error",
presented: true,
}).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.
process.on(
"exit",
// fallow-ignore-next-line complexity
(code) => {
if (!finalized) {
_trackCommandResult?.({
command,
success: code === 0 && commandSucceededForTelemetry(),
exitCode: code,
durationMs: Date.now() - commandStart,
runId,
});
}
// Unconditional — `finalized` only means finalizeCli STARTED its awaited
// flush(). A process.exit() racing that flush (the EPIPE path under agent
// pipes) kills the in-flight request, and gating this fallback behind
// `finalized` silently dropped the still-queued events — the 0.7.65
// render_complete regression. flushSync() is safe to over-call: an empty
// queue is a no-op, and event uuids make re-sends idempotent.
_flushSync?.();
},
);
// Report a CLI error event to telemetry. Extracted from the process-error
// handlers so their bodies stay simple linear branches (see fallow CRAP
// scoring — arrow handlers with inline telemetry calls tip past threshold).
function emitCliErrorEvent(kind: "uncaught_exception" | "unhandled_rejection", error: Error): void {
_trackCliError?.({
error_name: error.name,
error_message: error.message,
stack_trace: error.stack,
command,
kind,
});
}
// Handle a post-artifact-validated throw: record the diagnostic, emit the
// telemetry event, but do NOT mark the run as failed. Field signals:
// ts=1784169760, ts=1784171150, ts=1784172467 (all win32/x64, CLI 0.7.58,
// ffmpeg=no, 1080x1920, valid MP4s on disk).
// The render is valid — a worker teardown / browser shutdown / stray
// subprocess stream error after `renderSucceeded` was set must not flip
// exit code or telemetry success to failure.
function reportPostRenderTerminationEvent(
label: "uncaughtException" | "unhandledRejection",
kind: "uncaught_exception" | "unhandled_rejection",
error: Error,
): void {
process.stderr.write(
` [hyperframes] Post-render ${label} (render already succeeded): ${error.message}\n`,
);
emitCliErrorEvent(kind, error);
}
// Terminate the process after a post-artifact-validated throw. Wraps
// report + flush + exit(0) so the caller arrow handler doesn't accumulate
// optional-chain branches (fallow CRAP scoring on the arrow tips past
// threshold otherwise).
function exitAfterPostRenderTermination(
label: "uncaughtException" | "unhandledRejection",
kind: "uncaught_exception" | "unhandled_rejection",
error: Error,
): never {
reportPostRenderTerminationEvent(label, kind, error);
_flushSync?.();
process.exit(0);
}
// A closed pipe (EPIPE) is the NORMAL teardown when the CLI runs under a
// piped agent (Claude Code, Codex, …) — the reader may stop consuming as
// soon as it has what it needs. Exit cleanly, but only score the run as a
// failure when the pipe died BEFORE the render artifact was validated:
// unconditionally setting `commandFailed = true` here marked every piped
// successful render as success:false (0.7.65–0.7.90). Delivery of anything
// still queued (render_complete's eager flush() dies with the process) is
// owned by the unconditional flushSync() in the `exit` handler below.
function handleStreamEpipe(): never {
if (!isRenderSucceeded()) commandFailed = true;
process.exit(0);
}
// Success gate for the cli_command_result telemetry field. `commandFailed`
// can be set by pre-artifact noise — a stray unhandledRejection mid-render,
// or an EPIPE that fires before validation on a run that still completes.
// Once the render artifact has been validated (`isRenderSucceeded()`), that
// earlier noise must not score the run as a failure: the run delivered.
// Genuine failures keep a non-zero exit code and are caught by the
// `exitCode === 0 &&` half of the expression at both call sites.
function commandSucceededForTelemetry(): boolean {
return !commandFailed || isRenderSucceeded();
}
// Terminate the process after a genuine CLI failure — mark commandFailed,
// emit telemetry, flush, exit(1). Same rationale as above: keeps the arrow
// handler linear so fallow CRAP stays under threshold.
function exitAfterCliFailure(
kind: "uncaught_exception" | "unhandled_rejection",
error: Error,
): never {
commandFailed = true;
emitCliErrorEvent(kind, error);
_flushSync?.();
process.exit(1);
}
process.on("uncaughtException", (error) => {
if ((error as NodeJS.ErrnoException).code === "EPIPE") {
handleStreamEpipe();
}
// Post-artifact-validated shutdown throws must not turn a valid render
// into an exit-1 "no final error message" failure. The render command
// sets `renderSucceeded` right after the producer resolves and the
// artifact is committed.
if (isRenderSucceeded()) {
exitAfterPostRenderTermination("uncaughtException", "uncaught_exception", error);
}
exitAfterCliFailure("uncaught_exception", error);
});
// unhandledRejection does not call process.exit() — Node may continue
// running if the rejection is non-fatal (e.g. a fire-and-forget promise).
// The exit handler above will still fire with the real exit code.
process.on("unhandledRejection", (reason) => {
const error = reason instanceof Error ? reason : new Error(String(reason));
// Same rationale as the uncaughtException branch above: a stray promise
// rejection during post-artifact-validated cleanup must not mark a valid
// render as failed. `commandFailed` gates the success:true telemetry
// field — keep it false when the render actually succeeded.
if (isRenderSucceeded()) {
reportPostRenderTerminationEvent("unhandledRejection", "unhandled_rejection", error);
return;
}
commandFailed = true;
process.exitCode = 1;
emitCliErrorEvent("unhandled_rejection", error);
});
// Lazy-load help renderer — avoids allocating help data on non-help invocations
async function showUsage<T extends ArgsDef>(
cmd: CommandDef<T>,
parent?: CommandDef<T>,
): Promise<void> {
const { showUsage: impl } = await import("./help.js");
return impl(cmd as CommandDef, parent as CommandDef | undefined);
}
async function showRequestedUsage(): Promise<void> {
const requested = await resolveCommandUsage(main as CommandDef, argv);
return showUsage(requested.command, requested.parent);
}
function commandResultForError(error: unknown): CommandResult {
if (error instanceof CliResultSignal) return error.result;
if (error instanceof CliUsageError || error instanceof CliRuntimeError) return error.result;
return { exitCode: 1, kind: "runtime_error" };
}
// Root-only command boundary; keeping every result path here prevents modules
// from bypassing output, telemetry, or finalizers.
// fallow-ignore-next-line complexity
async function executeCli(): Promise<void> {
let result: CommandResult = { exitCode: 0, kind: "success" };
try {
if (isHelp) await showRequestedUsage();
else await runCommand(main, { rawArgs: argv });
} catch (error) {
result = commandResultForError(error);
if (!(error instanceof CliResultSignal)) {
commandFailed = true;
await reportCommandFailure(command, error);
const typed = error instanceof CliUsageError || error instanceof CliRuntimeError;
if (error instanceof CliUsageError && !error.result.presented) await showRequestedUsage();
if (!typed || !error.result.presented) {
console.error(error instanceof Error ? error.message : String(error));
}
}
} finally {
const pending = consumeCommandResult();
if (pending.exitCode !== 0 || result.exitCode === 0) result = pending;
await finalizeCli(result);
}
}
await executeCli();