@@ -18,6 +18,8 @@ import {
1818 planHostnameFoldMigration ,
1919 sourceLocalPath ,
2020 _resetGbrainSupportsRenameCache ,
21+ codeStageTimeoutMs ,
22+ memoryStageTimeoutMs ,
2123} from "../bin/gstack-gbrain-sync" ;
2224
2325const 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