Skip to content

Commit 0e5562c

Browse files
vanceingallsclaude
andcommitted
fix(cli): keep a set-but-empty router env var breaker-managed (review)
Ownership detection classified ANY defined HF_DE_PARALLEL_ROUTER as a user choice, but both parsers read empty/whitespace as "unset -> default ON". Launching with `HF_DE_PARALLEL_ROUTER=` therefore routed the render (empty parses as ON) while exempting the install from its circuit breaker: after a verified fallback applyDeParallelRouterBreaker() no-op'd, so the install kept retrying the failing router instead of latching off. That is the exact first-fallback protection this PR exists to provide, lost on a documented default path. Ownership now uses the same normalization as the parsers. Also: only announce a trip the breaker could act on. With an explicit user opt-in the breaker is deliberately a no-op, so "now off for this install" was factually wrong — and reprinted on every later revert, since the user's value keeps the router active. Tests: set-but-empty and whitespace both latch off and persist the fired flag (fault-injection verified — restoring the old check fails both); explicit "true" survives a fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1f91a35 commit 0e5562c

2 files changed

Lines changed: 59 additions & 3 deletions

File tree

packages/cli/src/commands/render.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,51 @@ describe("renderLocal — DE parallel-router circuit breaker", () => {
795795
expect(process.env.HF_DE_PARALLEL_ROUTER).toBe("false");
796796
});
797797

798+
for (const emptyish of ["", " "]) {
799+
it(`treats a set-but-empty env var (${JSON.stringify(emptyish)}) as default, not a user choice`, async () => {
800+
// Both parsers read empty/whitespace as "unset → default ON", so the
801+
// producer routes. If ownership instead treated any defined value as a
802+
// user choice, the breaker would no-op and this install would keep
803+
// retrying a failing router forever — losing the first-fallback
804+
// protection that is the point of the breaker.
805+
configState.disk = {
806+
telemetryEnabled: true,
807+
deParallelRouterTrialFired: false,
808+
telemetryNoticeShown: true,
809+
};
810+
process.env.HF_DE_PARALLEL_ROUTER = emptyish;
811+
producerState.executeImpl = async (job) => {
812+
job.perfSummary = {
813+
resolution: { width: 100, height: 100 },
814+
drawElement: { parallelRouter: "reverted" },
815+
};
816+
};
817+
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
818+
expect(process.env.HF_DE_PARALLEL_ROUTER).toBe("false");
819+
expect(configState.writeConfigCalls).toContainEqual(
820+
expect.objectContaining({ deParallelRouterTrialFired: true }),
821+
);
822+
});
823+
}
824+
825+
it("does not override an explicit user opt-in even after a fallback", async () => {
826+
// "Explicit user choice wins in both directions" — the opt-in half.
827+
configState.disk = {
828+
telemetryEnabled: true,
829+
deParallelRouterTrialFired: false,
830+
telemetryNoticeShown: true,
831+
};
832+
process.env.HF_DE_PARALLEL_ROUTER = "true";
833+
producerState.executeImpl = async (job) => {
834+
job.perfSummary = {
835+
resolution: { width: 100, height: 100 },
836+
drawElement: { parallelRouter: "reverted" },
837+
};
838+
};
839+
await renderLocal("/tmp/project", "/tmp/out.mp4", baseOptions);
840+
expect(process.env.HF_DE_PARALLEL_ROUTER).toBe("true");
841+
});
842+
798843
it("keeps the router on for a telemetry opt-out — analytics choice must not cost performance", async () => {
799844
// The old trial refused to arm without recordable telemetry (no point
800845
// running an experiment you can't measure). Now that the router is a

packages/cli/src/commands/render.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ import {
6868
writeConfig,
6969
type HyperframesConfig,
7070
} from "../telemetry/config.js";
71-
import { shouldTrack } from "../telemetry/client.js";
7271
import { renderJobObservabilityTelemetryPayload } from "../telemetry/renderObservability.js";
7372
import { bytesToMb } from "../telemetry/system.js";
7473
import { VERSION } from "../version.js";
@@ -1184,8 +1183,16 @@ function applyDeParallelRouterBreaker(): void {
11841183
function applyDeParallelRouterCircuitBreaker(quiet: boolean): boolean {
11851184
// Latch the user's own choice on first observation, BEFORE the breaker can
11861185
// write the var itself and make the two indistinguishable.
1186+
//
1187+
// Ownership uses the SAME normalization as the two parsers: a set-but-empty
1188+
// (or whitespace) value means "unset / default ON", so it is NOT a user
1189+
// choice and must stay breaker-managed. Treating any defined value as
1190+
// user-managed would let `HF_DE_PARALLEL_ROUTER=` route the render (empty
1191+
// parses as ON) while exempting that install from the breaker — it would
1192+
// keep retrying a failing router forever, losing exactly the first-fallback
1193+
// protection this PR exists to provide (review finding).
11871194
if (!deParallelRouterUserManagedResolved) {
1188-
deParallelRouterUserManaged = process.env.HF_DE_PARALLEL_ROUTER !== undefined;
1195+
deParallelRouterUserManaged = (process.env.HF_DE_PARALLEL_ROUTER ?? "").trim() !== "";
11891196
deParallelRouterUserManagedResolved = true;
11901197
}
11911198
if (deParallelRouterUserManaged) {
@@ -1315,7 +1322,11 @@ function maybeConsumeDeParallelRouterTrial(
13151322
applyDeParallelRouterBreaker();
13161323
}
13171324
writeConfig(config);
1318-
if (fired) reportDeParallelRouterBreakerTrip(quiet);
1325+
// Only announce a trip the breaker could actually act on. With an explicit
1326+
// user opt-in the breaker is a no-op, so "now off for this install" would
1327+
// be false — and would reprint on every subsequent revert, since the user's
1328+
// value keeps the router active (review finding).
1329+
if (fired && !deParallelRouterUserManaged) reportDeParallelRouterBreakerTrip(quiet);
13191330
}
13201331

13211332
/**

0 commit comments

Comments
 (0)