@@ -7,6 +7,84 @@ import { join } from "path";
77const timeout = isDebug ? Infinity : 10_000 ;
88const longTimeout = isDebug ? Infinity : 30_000 ;
99
10+ /**
11+ * Helper to parse stderr from a --hot process that throws errors.
12+ * Drives the reload cycle: reads error lines from stderr, verifies them,
13+ * and calls onReload to trigger the next file change.
14+ *
15+ * This fixes the original `continue outer` pattern which discarded any
16+ * remaining buffered lines from the current chunk when a duplicate error
17+ * was encountered, potentially losing data and causing test hangs.
18+ */
19+ async function driveErrorReloadCycle (
20+ runner : ReturnType < typeof spawn > ,
21+ opts : {
22+ targetCount : number ;
23+ onReload : ( counter : number ) => void ;
24+ verifyLine ?: ( errorLine : string , nextLine : string | undefined , counter : number ) => void ;
25+ } ,
26+ ) : Promise < number > {
27+ const { targetCount, onReload, verifyLine } = opts ;
28+ let reloadCounter = 0 ;
29+ let str = "" ;
30+
31+ for await ( const chunk of runner . stderr ) {
32+ str += new TextDecoder ( ) . decode ( chunk ) ;
33+ // Need at least one error line followed by a newline, then another line followed by a newline
34+ if ( ! / e r r o r : .* [ 0 - 9 ] \n .* ?\n / g. test ( str ) ) continue ;
35+
36+ const lines = str . split ( "\n" ) ;
37+ // Preserve trailing partial line for the next chunk
38+ str = lines . pop ( ) ?? "" ;
39+ let triggered = false ;
40+
41+ for ( let i = 0 ; i < lines . length ; i ++ ) {
42+ const line = lines [ i ] ;
43+ if ( ! line . includes ( "error:" ) ) continue ;
44+
45+ if ( reloadCounter >= targetCount ) {
46+ runner . kill ( ) ;
47+ return reloadCounter ;
48+ }
49+
50+ // If we see the previous error repeated, the pending reload hasn't
51+ // taken effect yet. Re-save the file and put remaining unprocessed
52+ // lines back into the buffer so they aren't lost.
53+ if ( line . includes ( `error: ${ reloadCounter - 1 } ` ) ) {
54+ const remaining = lines . slice ( i + 1 ) . join ( "\n" ) ;
55+ if ( remaining ) {
56+ str = `${ remaining } \n${ str } ` ;
57+ }
58+ onReload ( reloadCounter ) ;
59+ triggered = false ; // onReload already called; skip post-loop call
60+ break ;
61+ }
62+
63+ expect ( line ) . toContain ( `error: ${ reloadCounter } ` ) ;
64+
65+ const nextLine = lines [ i + 1 ] ;
66+ if ( verifyLine ) {
67+ verifyLine ( line , nextLine , reloadCounter ) ;
68+ i ++ ; // Skip the next line (stack trace)
69+ }
70+
71+ reloadCounter ++ ;
72+ triggered = true ;
73+
74+ if ( reloadCounter >= targetCount ) {
75+ runner . kill ( ) ;
76+ return reloadCounter ;
77+ }
78+ }
79+
80+ if ( triggered ) {
81+ onReload ( reloadCounter ) ;
82+ }
83+ }
84+
85+ return reloadCounter ;
86+ }
87+
1088let hotRunnerRoot : string = "" ,
1189 cwd = "" ;
1290beforeEach ( ( ) => {
414492 timeout ,
415493) ;
416494
417- const comment_spam = ( "//" + "B" . repeat ( 2000 ) + "\n" ) . repeat ( 1000 ) ;
495+ const comment_line = "//" + Buffer . alloc ( 2000 , "B" ) . toString ( ) + "\n" ;
496+ const comment_spam = Buffer . alloc ( comment_line . length * 1000 , comment_line ) . toString ( ) ;
418497it (
419498 "should work with sourcemap generation" ,
420499 async ( ) => {
@@ -432,50 +511,24 @@ throw new Error('0');`,
432511 stderr : "pipe" ,
433512 stdin : "ignore" ,
434513 } ) ;
435- let reloadCounter = 0 ;
436- function onReload ( ) {
437- writeFileSync (
438- hotRunnerRoot ,
439- `// source content
514+ const reloadCounter = await driveErrorReloadCycle ( runner , {
515+ targetCount : 50 ,
516+ onReload : counter => {
517+ writeFileSync (
518+ hotRunnerRoot ,
519+ `// source content
440520${ comment_spam }
441- ${ " " . repeat ( reloadCounter * 2 ) } throw new Error(${ reloadCounter } );`,
442- ) ;
443- }
444- let str = "" ;
445- outer: for await ( const chunk of runner . stderr ) {
446- str += new TextDecoder ( ) . decode ( chunk ) ;
447- var any = false ;
448- if ( ! / e r r o r : .* [ 0 - 9 ] \n .* ?\n / g. test ( str ) ) continue ;
449-
450- let it = str . split ( "\n" ) ;
451- let line ;
452- while ( ( line = it . shift ( ) ) ) {
453- if ( ! line . includes ( "error:" ) ) continue ;
454- str = "" ;
455-
456- if ( reloadCounter === 50 ) {
457- runner . kill ( ) ;
458- break ;
459- }
460-
461- if ( line . includes ( `error: ${ reloadCounter - 1 } ` ) ) {
462- onReload ( ) ; // re-save file to prevent deadlock
463- continue outer;
464- }
465- expect ( line ) . toContain ( `error: ${ reloadCounter } ` ) ;
466- reloadCounter ++ ;
467-
468- let next = it . shift ( ) ! ;
469- if ( ! next ) throw new Error ( line ) ;
470- const match = next . match ( / \s * a t .* ?: 1 0 0 3 : ( \d + ) $ / ) ;
471- if ( ! match ) throw new Error ( "invalid string: " + next ) ;
521+ ${ Buffer . alloc ( counter * 2 , " " ) . toString ( ) } throw new Error(${ counter } );`,
522+ ) ;
523+ } ,
524+ verifyLine : ( errorLine , nextLine , counter ) => {
525+ if ( ! nextLine ) throw new Error ( errorLine ) ;
526+ const match = nextLine . match ( / \s * a t .* ?: 1 0 0 3 : ( \d + ) $ / ) ;
527+ if ( ! match ) throw new Error ( "invalid string: " + nextLine ) ;
472528 const col = match [ 1 ] ;
473- expect ( Number ( col ) ) . toBe ( 1 + "throw new " . length + ( reloadCounter - 1 ) * 2 ) ;
474- any = true ;
475- }
476-
477- if ( any ) await onReload ( ) ;
478- }
529+ expect ( Number ( col ) ) . toBe ( 1 + "throw new " . length + counter * 2 ) ;
530+ } ,
531+ } ) ;
479532 await runner . exited ;
480533 expect ( reloadCounter ) . toBe ( 50 ) ;
481534 } ,
@@ -498,8 +551,8 @@ throw new Error('0');`,
498551 cmd : [ bunExe ( ) , "build" , "--watch" , bundleIn , "--target=bun" , "--sourcemap=inline" , "--outfile" , hotRunnerRoot ] ,
499552 env : bunEnv ,
500553 cwd,
501- stdout : "inherit " ,
502- stderr : "inherit " ,
554+ stdout : "ignore " ,
555+ stderr : "ignore " ,
503556 stdin : "ignore" ,
504557 } ) ;
505558 waitForFileToExist ( hotRunnerRoot , 20 ) ;
@@ -511,57 +564,42 @@ throw new Error('0');`,
511564 stderr : "pipe" ,
512565 stdin : "ignore" ,
513566 } ) ;
514- let reloadCounter = 0 ;
515- function onReload ( ) {
516- writeFileSync (
517- bundleIn ,
518- `// source content
567+ let done = false ;
568+ const reloadCounter = await Promise . race ( [
569+ driveErrorReloadCycle ( runner , {
570+ targetCount : 50 ,
571+ onReload : counter => {
572+ writeFileSync (
573+ bundleIn ,
574+ `// source content
519575// etc etc
520576// etc etc
521- ${ " " . repeat ( reloadCounter * 2 ) } throw new Error(${ reloadCounter } );`,
522- ) ;
523- }
524- let str = "" ;
525- outer: for await ( const chunk of runner . stderr ) {
526- const s = new TextDecoder ( ) . decode ( chunk ) ;
527- str += s ;
528- var any = false ;
529- if ( ! / e r r o r : .* [ 0 - 9 ] \n .* ?\n / g. test ( str ) ) continue ;
530-
531- let it = str . split ( "\n" ) ;
532- let line ;
533- while ( ( line = it . shift ( ) ) ) {
534- if ( ! line . includes ( "error:" ) ) continue ;
535- str = "" ;
536-
537- if ( reloadCounter === 50 ) {
538- runner . kill ( ) ;
539- break ;
540- }
541-
542- if ( line . includes ( `error: ${ reloadCounter - 1 } ` ) ) {
543- onReload ( ) ; // re-save file to prevent deadlock
544- continue outer;
545- }
546- expect ( line ) . toContain ( `error: ${ reloadCounter } ` ) ;
547- reloadCounter ++ ;
548-
549- let next = it . shift ( ) ! ;
550- expect ( next ) . toInclude ( "bundle_in.ts" ) ;
551- const col = next . match ( / \s * a t .* ?: 4 : ( \d + ) $ / ) ! [ 1 ] ;
552- expect ( Number ( col ) ) . toBe ( 1 + "throw " . length + ( reloadCounter - 1 ) * 2 ) ;
553- any = true ;
554- }
555-
556- if ( any ) await onReload ( ) ;
557- }
577+ ${ Buffer . alloc ( counter * 2 , " " ) . toString ( ) } throw new Error(${ counter } );`,
578+ ) ;
579+ } ,
580+ verifyLine : ( _errorLine , nextLine , counter ) => {
581+ if ( ! nextLine ) throw new Error ( _errorLine ) ;
582+ expect ( nextLine ) . toInclude ( "bundle_in.ts" ) ;
583+ const match = nextLine . match ( / \s * a t .* ?: 4 : ( \d + ) $ / ) ;
584+ if ( ! match ) throw new Error ( "invalid stack trace: " + nextLine ) ;
585+ const col = match [ 1 ] ;
586+ expect ( Number ( col ) ) . toBe ( 1 + "throw " . length + counter * 2 ) ;
587+ } ,
588+ } ) . finally ( ( ) => {
589+ done = true ;
590+ } ) ,
591+ bundler . exited . then ( code => {
592+ if ( ! done ) throw new Error ( `bundler exited early with code ${ code } ` ) ;
593+ return - 1 ; // Ignored — race already resolved
594+ } ) ,
595+ ] ) ;
558596 expect ( reloadCounter ) . toBe ( 50 ) ;
559597 bundler . kill ( ) ;
560598 } ,
561599 timeout ,
562600) ;
563601
564- const long_comment = "BBBB" . repeat ( 100000 ) ;
602+ const long_comment = Buffer . alloc ( 400000 , "BBBB" ) . toString ( ) ;
565603
566604it (
567605 "should work with sourcemap loading with large files" ,
@@ -604,68 +642,43 @@ throw new Error('0');`,
604642 ] ,
605643 env : bunEnv ,
606644 cwd,
607- stdout : "inherit " ,
645+ stdout : "ignore " ,
608646 stderr : "pipe" ,
609647 stdin : "ignore" ,
610648 } ) ;
611- let reloadCounter = 0 ;
612- function onReload ( ) {
613- writeFileSync (
614- bundleIn ,
615- `// ${ long_comment }
649+ let done2 = false ;
650+ const reloadCounter = await Promise . race ( [
651+ driveErrorReloadCycle ( runner , {
652+ targetCount : 50 ,
653+ onReload : counter => {
654+ writeFileSync (
655+ bundleIn ,
656+ `// ${ long_comment }
616657console.error("RSS: %s", process.memoryUsage().rss);
617658//
618- ${ " " . repeat ( reloadCounter * 2 ) } throw new Error(${ reloadCounter } );`,
619- ) ;
620- }
621- let str = "" ;
622- let sampleMemory10 : number | undefined ;
623- let sampleMemory100 : number | undefined ;
624- outer: for await ( const chunk of runner . stderr ) {
625- str += new TextDecoder ( ) . decode ( chunk ) ;
626- var any = false ;
627- if ( ! / e r r o r : .* [ 0 - 9 ] \n .* ?\n / g. test ( str ) ) continue ;
628-
629- let it = str . split ( "\n" ) ;
630- let line ;
631- while ( ( line = it . shift ( ) ) ) {
632- if ( ! line . includes ( "error:" ) ) continue ;
633- let rssMatch = str . match ( / R S S : ( \d + ( \. \d + ) ? ) \n / ) ;
634- let rss ;
635- if ( rssMatch ) rss = Number ( rssMatch [ 1 ] ) ;
636- str = "" ;
637-
638- if ( reloadCounter == 10 ) {
639- sampleMemory10 = rss ;
640- }
641-
642- if ( reloadCounter >= 50 ) {
643- sampleMemory100 = rss ;
644- runner . kill ( ) ;
645- break ;
646- }
647-
648- if ( line . includes ( `error: ${ reloadCounter - 1 } ` ) ) {
649- onReload ( ) ; // re-save file to prevent deadlock
650- continue outer;
651- }
652- expect ( line ) . toContain ( `error: ${ reloadCounter } ` ) ;
653-
654- reloadCounter ++ ;
655- let next = it . shift ( ) ! ;
656- expect ( next ) . toInclude ( "bundle_in.ts" ) ;
657- const col = next . match ( / \s * a t .* ?: 4 : ( \d + ) $ / ) ! [ 1 ] ;
658- expect ( Number ( col ) ) . toBe ( 1 + "throw " . length + ( reloadCounter - 1 ) * 2 ) ;
659- any = true ;
660- }
661-
662- if ( any ) await onReload ( ) ;
663- }
659+ ${ Buffer . alloc ( counter * 2 , " " ) . toString ( ) } throw new Error(${ counter } );`,
660+ ) ;
661+ } ,
662+ verifyLine : ( _errorLine , nextLine , counter ) => {
663+ if ( ! nextLine ) throw new Error ( _errorLine ) ;
664+ expect ( nextLine ) . toInclude ( "bundle_in.ts" ) ;
665+ const match = nextLine . match ( / \s * a t .* ?: 4 : ( \d + ) $ / ) ;
666+ if ( ! match ) throw new Error ( "invalid stack trace: " + nextLine ) ;
667+ const col = match [ 1 ] ;
668+ expect ( Number ( col ) ) . toBe ( 1 + "throw " . length + counter * 2 ) ;
669+ } ,
670+ } ) . finally ( ( ) => {
671+ done2 = true ;
672+ } ) ,
673+ bundler . exited . then ( code => {
674+ if ( ! done2 ) throw new Error ( `bundler exited early with code ${ code } ` ) ;
675+ return - 1 ; // Ignored — race already resolved
676+ } ) ,
677+ ] ) ;
664678 expect ( reloadCounter ) . toBe ( 50 ) ;
665679 bundler . kill ( ) ;
666680 await runner . exited ;
667681 // TODO: bun has a memory leak when --hot is used on very large files
668- // console.log({ sampleMemory10, sampleMemory100 });
669682 } ,
670683 longTimeout ,
671684) ;
0 commit comments