@@ -792,6 +792,144 @@ describe('consult command', () => {
792792 } ) ;
793793 } ) ;
794794
795+ describe ( 'Gemini large-prompt crash mitigation (Bugfix #680)' , ( ) => {
796+ // V8 old-space exhaustion crashed gemini-cli v0.37.x on PR diffs >500KB.
797+ // Fix: bump heap via NODE_OPTIONS and pipe the prompt via stdin (no argv).
798+
799+ it ( 'should bump NODE_OPTIONS heap when spawning gemini' , async ( ) => {
800+ vi . resetModules ( ) ;
801+ const { spawn : spawnBefore } = await import ( 'node:child_process' ) ;
802+ vi . mocked ( spawnBefore ) . mockClear ( ) ;
803+
804+ fs . mkdirSync ( path . join ( testBaseDir , 'codev' , 'roles' ) , { recursive : true } ) ;
805+ fs . writeFileSync (
806+ path . join ( testBaseDir , 'codev' , 'roles' , 'consultant.md' ) ,
807+ '# Consultant Role'
808+ ) ;
809+ process . chdir ( testBaseDir ) ;
810+
811+ const { execSync } = await import ( 'node:child_process' ) ;
812+ vi . mocked ( execSync ) . mockImplementation ( ( cmd : string ) => {
813+ if ( cmd . includes ( 'which' ) ) return Buffer . from ( '/usr/bin/gemini' ) ;
814+ return Buffer . from ( '' ) ;
815+ } ) ;
816+
817+ const { spawn } = await import ( 'node:child_process' ) ;
818+ const { consult } = await import ( '../commands/consult/index.js' ) ;
819+
820+ await consult ( { model : 'gemini' , prompt : 'review this PR' } ) ;
821+
822+ const geminiCall = vi . mocked ( spawn ) . mock . calls . find ( call => call [ 0 ] === 'gemini' ) ;
823+ expect ( geminiCall ) . toBeDefined ( ) ;
824+ const spawnOpts = geminiCall ! [ 2 ] as { env ?: Record < string , string > } ;
825+ expect ( spawnOpts . env ) . toBeDefined ( ) ;
826+ expect ( spawnOpts . env ! . NODE_OPTIONS ) . toContain ( '--max-old-space-size=8192' ) ;
827+ } ) ;
828+
829+ it ( 'should NOT pass the query as a positional argv to gemini' , async ( ) => {
830+ // Large queries on argv risk E2BIG and force V8 to hold the prompt twice.
831+ // The query must flow through stdin, not argv.
832+ vi . resetModules ( ) ;
833+ const { spawn : spawnBefore } = await import ( 'node:child_process' ) ;
834+ vi . mocked ( spawnBefore ) . mockClear ( ) ;
835+
836+ fs . mkdirSync ( path . join ( testBaseDir , 'codev' , 'roles' ) , { recursive : true } ) ;
837+ fs . writeFileSync (
838+ path . join ( testBaseDir , 'codev' , 'roles' , 'consultant.md' ) ,
839+ '# Consultant Role'
840+ ) ;
841+ process . chdir ( testBaseDir ) ;
842+
843+ const { execSync } = await import ( 'node:child_process' ) ;
844+ vi . mocked ( execSync ) . mockImplementation ( ( cmd : string ) => {
845+ if ( cmd . includes ( 'which' ) ) return Buffer . from ( '/usr/bin/gemini' ) ;
846+ return Buffer . from ( '' ) ;
847+ } ) ;
848+
849+ const { spawn } = await import ( 'node:child_process' ) ;
850+ const { consult } = await import ( '../commands/consult/index.js' ) ;
851+
852+ const uniqueQuery = 'UNIQUE_BUGFIX_680_SENTINEL_' + Date . now ( ) ;
853+ await consult ( { model : 'gemini' , prompt : uniqueQuery } ) ;
854+
855+ const geminiCall = vi . mocked ( spawn ) . mock . calls . find ( call => call [ 0 ] === 'gemini' ) ;
856+ expect ( geminiCall ) . toBeDefined ( ) ;
857+ const args = geminiCall ! [ 1 ] as string [ ] ;
858+ expect ( args . some ( a => a . includes ( uniqueQuery ) ) ) . toBe ( false ) ;
859+ } ) ;
860+
861+ it ( 'should pipe the query to stdin instead of argv' , async ( ) => {
862+ // stdio[0] must be 'pipe' for gemini (so we can write the prompt), not 'ignore'.
863+ vi . resetModules ( ) ;
864+ const { spawn : spawnBefore } = await import ( 'node:child_process' ) ;
865+ vi . mocked ( spawnBefore ) . mockClear ( ) ;
866+
867+ fs . mkdirSync ( path . join ( testBaseDir , 'codev' , 'roles' ) , { recursive : true } ) ;
868+ fs . writeFileSync (
869+ path . join ( testBaseDir , 'codev' , 'roles' , 'consultant.md' ) ,
870+ '# Consultant Role'
871+ ) ;
872+ process . chdir ( testBaseDir ) ;
873+
874+ const { execSync } = await import ( 'node:child_process' ) ;
875+ vi . mocked ( execSync ) . mockImplementation ( ( cmd : string ) => {
876+ if ( cmd . includes ( 'which' ) ) return Buffer . from ( '/usr/bin/gemini' ) ;
877+ return Buffer . from ( '' ) ;
878+ } ) ;
879+
880+ const { spawn } = await import ( 'node:child_process' ) ;
881+ const { consult } = await import ( '../commands/consult/index.js' ) ;
882+
883+ await consult ( { model : 'gemini' , prompt : 'small prompt' } ) ;
884+
885+ const geminiCall = vi . mocked ( spawn ) . mock . calls . find ( call => call [ 0 ] === 'gemini' ) ;
886+ expect ( geminiCall ) . toBeDefined ( ) ;
887+ const spawnOpts = geminiCall ! [ 2 ] as { stdio ?: Array < string > } ;
888+ expect ( spawnOpts . stdio ) . toBeDefined ( ) ;
889+ expect ( spawnOpts . stdio ! [ 0 ] ) . toBe ( 'pipe' ) ;
890+ } ) ;
891+
892+ it ( 'should preserve the caller NODE_OPTIONS when appending max-old-space-size' , async ( ) => {
893+ vi . resetModules ( ) ;
894+ const { spawn : spawnBefore } = await import ( 'node:child_process' ) ;
895+ vi . mocked ( spawnBefore ) . mockClear ( ) ;
896+
897+ fs . mkdirSync ( path . join ( testBaseDir , 'codev' , 'roles' ) , { recursive : true } ) ;
898+ fs . writeFileSync (
899+ path . join ( testBaseDir , 'codev' , 'roles' , 'consultant.md' ) ,
900+ '# Consultant Role'
901+ ) ;
902+ process . chdir ( testBaseDir ) ;
903+
904+ const { execSync } = await import ( 'node:child_process' ) ;
905+ vi . mocked ( execSync ) . mockImplementation ( ( cmd : string ) => {
906+ if ( cmd . includes ( 'which' ) ) return Buffer . from ( '/usr/bin/gemini' ) ;
907+ return Buffer . from ( '' ) ;
908+ } ) ;
909+
910+ const priorNodeOptions = process . env . NODE_OPTIONS ;
911+ process . env . NODE_OPTIONS = '--enable-source-maps' ;
912+ try {
913+ const { spawn } = await import ( 'node:child_process' ) ;
914+ const { consult } = await import ( '../commands/consult/index.js' ) ;
915+
916+ await consult ( { model : 'gemini' , prompt : 'test' } ) ;
917+
918+ const geminiCall = vi . mocked ( spawn ) . mock . calls . find ( call => call [ 0 ] === 'gemini' ) ;
919+ expect ( geminiCall ) . toBeDefined ( ) ;
920+ const spawnOpts = geminiCall ! [ 2 ] as { env ?: Record < string , string > } ;
921+ expect ( spawnOpts . env ! . NODE_OPTIONS ) . toContain ( '--enable-source-maps' ) ;
922+ expect ( spawnOpts . env ! . NODE_OPTIONS ) . toContain ( '--max-old-space-size=8192' ) ;
923+ } finally {
924+ if ( priorNodeOptions === undefined ) {
925+ delete process . env . NODE_OPTIONS ;
926+ } else {
927+ process . env . NODE_OPTIONS = priorNodeOptions ;
928+ }
929+ }
930+ } ) ;
931+ } ) ;
932+
795933 describe ( 'diff stat approach (Bugfix #240)' , ( ) => {
796934 it ( 'should export getDiffStat for file-based review' , async ( ) => {
797935 vi . resetModules ( ) ;
0 commit comments