Skip to content

Commit 5b59f72

Browse files
committed
fix(gbrain-sync): make stage timeouts configurable via env (garrytan#1611)
`/sync-gbrain --full` on ~100k-page brains reliably exceeded the hard-coded 35-minute timeout, SIGTERMed mid-import, and lost the staging checkpoint. Set GSTACK_SYNC_MEMORY_TIMEOUT_MS (or _CODE_ for the code stage) to override; bad input falls back to the 35-min default with a stderr warning so a typo can't silently disable the safety net.
1 parent 029356e commit 5b59f72

2 files changed

Lines changed: 116 additions & 2 deletions

File tree

bin/gstack-gbrain-sync.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,35 @@ const STATE_PATH = join(GSTACK_HOME, ".gbrain-sync-state.json");
8080
const LOCK_PATH = join(GSTACK_HOME, ".sync-gbrain.lock");
8181
const STALE_LOCK_MS = 5 * 60 * 1000;
8282

83+
// Per-stage timeouts. Default 35 minutes is the honest budget for a first-run
84+
// full sync of a ~30-page-per-second brain (~70k pages). Brains with 100k+
85+
// pages or slow IO need more headroom — issue #1611. The env knobs accept an
86+
// integer in milliseconds; non-positive or non-numeric values fall back to the
87+
// default with a stderr warning so a typo doesn't silently extend a stage
88+
// indefinitely.
89+
const DEFAULT_STAGE_TIMEOUT_MS = 35 * 60 * 1000;
90+
91+
function parseTimeoutEnv(name: string): number {
92+
const raw = process.env[name];
93+
if (!raw) return DEFAULT_STAGE_TIMEOUT_MS;
94+
const n = Number(raw);
95+
if (!Number.isFinite(n) || n <= 0) {
96+
console.warn(
97+
`[gstack-gbrain-sync] ignoring ${name}=${JSON.stringify(raw)} — expected a positive integer (milliseconds); using default ${DEFAULT_STAGE_TIMEOUT_MS} ms`,
98+
);
99+
return DEFAULT_STAGE_TIMEOUT_MS;
100+
}
101+
return Math.floor(n);
102+
}
103+
104+
export function codeStageTimeoutMs(): number {
105+
return parseTimeoutEnv("GSTACK_SYNC_CODE_TIMEOUT_MS");
106+
}
107+
108+
export function memoryStageTimeoutMs(): number {
109+
return parseTimeoutEnv("GSTACK_SYNC_MEMORY_TIMEOUT_MS");
110+
}
111+
83112
// ── CLI ────────────────────────────────────────────────────────────────────
84113

85114
function printUsage(): void {
@@ -100,6 +129,12 @@ Options:
100129
101130
Stages run in order: code → memory ingest → curated git push.
102131
Each stage failure is non-fatal; subsequent stages still run.
132+
133+
Environment:
134+
GSTACK_SYNC_CODE_TIMEOUT_MS Override code stage timeout (default 35 min).
135+
GSTACK_SYNC_MEMORY_TIMEOUT_MS Override memory ingest timeout (default 35 min).
136+
Set higher (e.g. 5400000 = 90 min) when --full
137+
import on large brains exceeds the default.
103138
`);
104139
}
105140

@@ -603,7 +638,7 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
603638

604639
const syncResult = spawnGbrain(syncArgs, {
605640
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
606-
timeout: 35 * 60 * 1000,
641+
timeout: codeStageTimeoutMs(),
607642
baseEnv: gbrainEnv,
608643
});
609644

@@ -757,7 +792,7 @@ function runMemoryIngest(args: CliArgs): StageResult {
757792
// internally and must see the DATABASE_URL from gbrain's own config.
758793
const result = spawnSync("bun", ingestArgs, {
759794
encoding: "utf-8",
760-
timeout: 35 * 60 * 1000,
795+
timeout: memoryStageTimeoutMs(),
761796
env: buildGbrainEnv({ announce: false }),
762797
});
763798

test/gstack-gbrain-sync.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
planHostnameFoldMigration,
1919
sourceLocalPath,
2020
_resetGbrainSupportsRenameCache,
21+
codeStageTimeoutMs,
22+
memoryStageTimeoutMs,
2123
} from "../bin/gstack-gbrain-sync";
2224

2325
const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-gbrain-sync.ts");
@@ -863,3 +865,80 @@ describe("sourceLocalPath", () => {
863865
expect(sourceLocalPath("missing-id", envWithBindir(bindir))).toBeNull();
864866
});
865867
});
868+
869+
// ──────────────────────────────────────────────────────────────────────────
870+
// Stage timeout overrides (issue #1611)
871+
//
872+
// `/sync-gbrain --full` on a ~100k-page brain blew past the hard-coded
873+
// 35-min timeout, SIGTERMed mid-import, and lost the staging checkpoint.
874+
// codeStageTimeoutMs / memoryStageTimeoutMs read env knobs so users with
875+
// slow IO or huge brains can extend the budget; bad inputs fall back to
876+
// the 35-min default so a typo doesn't silently disable the safety net.
877+
// ──────────────────────────────────────────────────────────────────────────
878+
879+
describe("stage timeout overrides (issue #1611)", () => {
880+
const DEFAULT_MS = 35 * 60 * 1000;
881+
const saved: Record<string, string | undefined> = {};
882+
const KEYS = ["GSTACK_SYNC_CODE_TIMEOUT_MS", "GSTACK_SYNC_MEMORY_TIMEOUT_MS"];
883+
884+
beforeEach(() => {
885+
for (const k of KEYS) {
886+
saved[k] = process.env[k];
887+
delete process.env[k];
888+
}
889+
});
890+
891+
afterEach(() => {
892+
for (const k of KEYS) {
893+
if (saved[k] === undefined) delete process.env[k];
894+
else process.env[k] = saved[k];
895+
}
896+
});
897+
898+
it("defaults to 35 minutes when no env knob is set", () => {
899+
expect(codeStageTimeoutMs()).toBe(DEFAULT_MS);
900+
expect(memoryStageTimeoutMs()).toBe(DEFAULT_MS);
901+
});
902+
903+
it("honors GSTACK_SYNC_MEMORY_TIMEOUT_MS for memory ingest", () => {
904+
process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS = "5400000"; // 90 min
905+
expect(memoryStageTimeoutMs()).toBe(5_400_000);
906+
// Code stage stays on default — env knobs are independent.
907+
expect(codeStageTimeoutMs()).toBe(DEFAULT_MS);
908+
});
909+
910+
it("honors GSTACK_SYNC_CODE_TIMEOUT_MS independently", () => {
911+
process.env.GSTACK_SYNC_CODE_TIMEOUT_MS = "7200000"; // 2 hr
912+
expect(codeStageTimeoutMs()).toBe(7_200_000);
913+
expect(memoryStageTimeoutMs()).toBe(DEFAULT_MS);
914+
});
915+
916+
it("rejects non-numeric input and falls back to default", () => {
917+
process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS = "ninety minutes";
918+
expect(memoryStageTimeoutMs()).toBe(DEFAULT_MS);
919+
});
920+
921+
it("rejects zero / negative values and falls back to default", () => {
922+
process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS = "0";
923+
expect(memoryStageTimeoutMs()).toBe(DEFAULT_MS);
924+
process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS = "-1";
925+
expect(memoryStageTimeoutMs()).toBe(DEFAULT_MS);
926+
});
927+
928+
it("floors fractional ms to an integer", () => {
929+
process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS = "1234.9";
930+
expect(memoryStageTimeoutMs()).toBe(1234);
931+
});
932+
933+
it("treats empty string as unset (falls back to default)", () => {
934+
process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS = "";
935+
expect(memoryStageTimeoutMs()).toBe(DEFAULT_MS);
936+
});
937+
938+
it("--help mentions the env knobs", () => {
939+
const r = runScript(["--help"]);
940+
expect(r.exitCode).toBe(0);
941+
expect(r.stderr).toContain("GSTACK_SYNC_MEMORY_TIMEOUT_MS");
942+
expect(r.stderr).toContain("GSTACK_SYNC_CODE_TIMEOUT_MS");
943+
});
944+
});

0 commit comments

Comments
 (0)