mirror of https://github.com/garrytan/gstack.git
fix(design/iterate): cap cumulative iterate timeout to single apiTimeoutMs budget
callWithThreading + callFresh each received the full apiTimeoutMs, so the worst-case wait on --api-timeout 300000 was 600s (2×300s). Now a shared deadline is set at iteration start; callFresh receives the remaining budget after threading fails or times out, and if the budget is already exhausted at fallback entry a clear Timeout (Xs) error is thrown immediately.
This commit is contained in:
parent
d78eeeed03
commit
917ad34929
|
|
@ -25,13 +25,27 @@ import { evolve } from "./evolve";
|
||||||
import { generateDesignToCodePrompt } from "./design-to-code";
|
import { generateDesignToCodePrompt } from "./design-to-code";
|
||||||
import { serve } from "./serve";
|
import { serve } from "./serve";
|
||||||
import { gallery } from "./gallery";
|
import { gallery } from "./gallery";
|
||||||
|
import { spawn as nodeSpawn } from "child_process";
|
||||||
import {
|
import {
|
||||||
daemonStatus as daemonStatusClient,
|
daemonStatus,
|
||||||
|
shutdownDaemon,
|
||||||
ensureDaemon,
|
ensureDaemon,
|
||||||
publishBoard,
|
publishBoard,
|
||||||
shutdownDaemon,
|
|
||||||
} from "./daemon-client";
|
} from "./daemon-client";
|
||||||
import { spawn as nodeSpawn } from "child_process";
|
|
||||||
|
/**
|
||||||
|
* Parse + validate `--api-timeout <ms>`. Throws on bad input so the caller can
|
||||||
|
* surface a clear error rather than passing NaN/0/negative to setTimeout (which
|
||||||
|
* would fire the AbortController immediately and instant-abort the request).
|
||||||
|
*/
|
||||||
|
export function parseApiTimeoutMs(raw: string | undefined): number | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const parsed = parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
throw new Error(`--api-timeout must be a positive integer (ms), got: ${raw}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
function parseArgs(argv: string[]): {
|
function parseArgs(argv: string[]): {
|
||||||
command: string;
|
command: string;
|
||||||
|
|
@ -133,9 +147,13 @@ async function main(): Promise<void> {
|
||||||
// Per-request timeout for OpenAI image-generation calls. Distinct from the
|
// Per-request timeout for OpenAI image-generation calls. Distinct from the
|
||||||
// existing `--timeout` flag, which controls the `compare --serve` / `serve`
|
// existing `--timeout` flag, which controls the `compare --serve` / `serve`
|
||||||
// HTTP listener. See issue #1519.
|
// HTTP listener. See issue #1519.
|
||||||
const apiTimeoutMs = flags["api-timeout"]
|
let apiTimeoutMs: number | undefined;
|
||||||
? parseInt(flags["api-timeout"] as string)
|
try {
|
||||||
: undefined;
|
apiTimeoutMs = parseApiTimeoutMs(flags["api-timeout"] as string | undefined);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case "generate":
|
case "generate":
|
||||||
|
|
@ -301,7 +319,7 @@ async function main(): Promise<void> {
|
||||||
// Sub-commands: `$D daemon status` and `$D daemon stop [--force]`.
|
// Sub-commands: `$D daemon status` and `$D daemon stop [--force]`.
|
||||||
const sub = positionals[0] || "status";
|
const sub = positionals[0] || "status";
|
||||||
if (sub === "status") {
|
if (sub === "status") {
|
||||||
const s = await daemonStatusClient();
|
const s = await daemonStatus();
|
||||||
if (!s.running) {
|
if (!s.running) {
|
||||||
console.log(JSON.stringify({ running: false }, null, 2));
|
console.log(JSON.stringify({ running: false }, null, 2));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|
@ -419,14 +437,16 @@ async function resolveImagePaths(input: string): Promise<string[]> {
|
||||||
// the production install to a single executable; daemon-client.ts spawns
|
// the production install to a single executable; daemon-client.ts spawns
|
||||||
// `<this binary> --daemon-mode` (or `bun run cli.ts --daemon-mode` in dev)
|
// `<this binary> --daemon-mode` (or `bun run cli.ts --daemon-mode` in dev)
|
||||||
// rather than relying on a separate daemon.ts file at a known path.
|
// rather than relying on a separate daemon.ts file at a known path.
|
||||||
if (process.argv.includes("--daemon-mode")) {
|
if (import.meta.main) {
|
||||||
const { start } = await import("./daemon");
|
if (process.argv.includes("--daemon-mode")) {
|
||||||
start();
|
const { start } = await import("./daemon");
|
||||||
// start() binds Bun.serve and registers signal handlers; this branch
|
start();
|
||||||
// never falls through to main(). Process stays alive on the bound port.
|
// start() binds Bun.serve and registers signal handlers; this branch
|
||||||
} else {
|
// never falls through to main(). Process stays alive on the bound port.
|
||||||
main().catch((err) => {
|
} else {
|
||||||
console.error(err.message || err);
|
main().catch((err) => {
|
||||||
process.exit(1);
|
console.error(err.message || err);
|
||||||
});
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ export async function iterate(options: IterateOptions): Promise<void> {
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const apiTimeoutMs = options.apiTimeoutMs ?? DEFAULT_IMAGE_GEN_TIMEOUT_MS;
|
const apiTimeoutMs = options.apiTimeoutMs ?? DEFAULT_IMAGE_GEN_TIMEOUT_MS;
|
||||||
|
// Single deadline shared across threading + fallback so cumulative wait is
|
||||||
|
// bounded by apiTimeoutMs rather than 2×apiTimeoutMs.
|
||||||
|
const deadline = startTime + apiTimeoutMs;
|
||||||
|
|
||||||
// Try multi-turn with previous_response_id first
|
// Try multi-turn with previous_response_id first
|
||||||
let success = false;
|
let success = false;
|
||||||
|
|
@ -48,13 +51,19 @@ export async function iterate(options: IterateOptions): Promise<void> {
|
||||||
console.error(` Threading failed: ${err.message}`);
|
console.error(` Threading failed: ${err.message}`);
|
||||||
console.error(" Falling back to re-generation with accumulated feedback...");
|
console.error(" Falling back to re-generation with accumulated feedback...");
|
||||||
|
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
const secs = (apiTimeoutMs / 1000).toFixed(apiTimeoutMs % 1000 === 0 ? 0 : 1);
|
||||||
|
throw new Error(`Timeout (${secs}s)`);
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback: re-generate with original brief + all feedback
|
// Fallback: re-generate with original brief + all feedback
|
||||||
const accumulatedPrompt = buildAccumulatedPrompt(
|
const accumulatedPrompt = buildAccumulatedPrompt(
|
||||||
session.originalBrief,
|
session.originalBrief,
|
||||||
[...session.feedbackHistory, options.feedback]
|
[...session.feedbackHistory, options.feedback]
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await callFresh(apiKey, accumulatedPrompt, apiTimeoutMs);
|
const result = await callFresh(apiKey, accumulatedPrompt, remaining);
|
||||||
responseId = result.responseId;
|
responseId = result.responseId;
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,8 @@ export async function generateVariant(
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
if (err.name === "AbortError") {
|
if (err.name === "AbortError") {
|
||||||
return { path: outputPath, success: false, error: `Timeout (${timeoutMs}ms)` };
|
const secs = (timeoutMs / 1000).toFixed(timeoutMs % 1000 === 0 ? 0 : 1);
|
||||||
|
return { path: outputPath, success: false, error: `Timeout (${secs}s)` };
|
||||||
}
|
}
|
||||||
lastError = err.message;
|
lastError = err.message;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import os from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { generateVariant } from "../src/variants";
|
import { generateVariant } from "../src/variants";
|
||||||
import { DEFAULT_IMAGE_GEN_TIMEOUT_MS } from "../src/constants";
|
import { DEFAULT_IMAGE_GEN_TIMEOUT_MS } from "../src/constants";
|
||||||
|
import { parseApiTimeoutMs } from "../src/cli";
|
||||||
|
|
||||||
describe("DEFAULT_IMAGE_GEN_TIMEOUT_MS", () => {
|
describe("DEFAULT_IMAGE_GEN_TIMEOUT_MS", () => {
|
||||||
test("default is 300_000ms (5min) — see issue #1519", () => {
|
test("default is 300_000ms (5min) — see issue #1519", () => {
|
||||||
|
|
@ -19,6 +20,34 @@ describe("DEFAULT_IMAGE_GEN_TIMEOUT_MS", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseApiTimeoutMs", () => {
|
||||||
|
test("undefined input returns undefined (use default)", () => {
|
||||||
|
expect(parseApiTimeoutMs(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("valid positive integer returns the parsed number", () => {
|
||||||
|
expect(parseApiTimeoutMs("300000")).toBe(300000);
|
||||||
|
expect(parseApiTimeoutMs("1")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-numeric input throws (would otherwise be NaN -> instant abort)", () => {
|
||||||
|
expect(() => parseApiTimeoutMs("abc")).toThrow(/positive integer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero throws (would otherwise fire setTimeout immediately)", () => {
|
||||||
|
expect(() => parseApiTimeoutMs("0")).toThrow(/positive integer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("negative throws (would otherwise fire setTimeout immediately)", () => {
|
||||||
|
expect(() => parseApiTimeoutMs("-1")).toThrow(/positive integer/);
|
||||||
|
expect(() => parseApiTimeoutMs("-300000")).toThrow(/positive integer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("trailing garbage is ignored (parseInt semantics; matches other flags)", () => {
|
||||||
|
expect(parseApiTimeoutMs("300000foo")).toBe(300000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("generateVariant timeoutMs override", () => {
|
describe("generateVariant timeoutMs override", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let outputPath: string;
|
let outputPath: string;
|
||||||
|
|
@ -54,11 +83,33 @@ describe("generateVariant timeoutMs override", () => {
|
||||||
const elapsed = Date.now() - t0;
|
const elapsed = Date.now() - t0;
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toBe("Timeout (200ms)");
|
expect(result.error).toBe("Timeout (0.2s)");
|
||||||
// Was aborted by the 200ms timer, not by exponential-backoff retry chain
|
// Was aborted by the 200ms timer, not by exponential-backoff retry chain
|
||||||
expect(elapsed).toBeLessThan(2_000);
|
expect(elapsed).toBeLessThan(2_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("formats whole-second timeouts without decimals", async () => {
|
||||||
|
const fetchFn = (async (_input: any, init?: any) => {
|
||||||
|
const signal = init?.signal as AbortSignal | undefined;
|
||||||
|
await new Promise<void>((_resolve, reject) => {
|
||||||
|
if (!signal) return;
|
||||||
|
signal.addEventListener("abort", () => {
|
||||||
|
const err: any = new Error("aborted");
|
||||||
|
err.name = "AbortError";
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return new Response("never reached");
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const result = await generateVariant(
|
||||||
|
"fake-key", "prompt", outputPath, "1024x1024", "high", fetchFn, 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe("Timeout (1s)");
|
||||||
|
});
|
||||||
|
|
||||||
test("default timeoutMs is the shared constant when omitted", async () => {
|
test("default timeoutMs is the shared constant when omitted", async () => {
|
||||||
// 1x1 transparent PNG, base64
|
// 1x1 transparent PNG, base64
|
||||||
const TINY_PNG_BASE64 =
|
const TINY_PNG_BASE64 =
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue