@@ -128,7 +128,15 @@ interface FakeProc extends EventEmitter {
128128type SpawnOutcome =
129129 | { kind : "missing" }
130130 | { kind : "error" ; message : string ; code ?: string }
131- | { kind : "exit" ; code : number ; stdout ?: string ; stderr ?: string } ;
131+ | {
132+ kind : "exit" ;
133+ code : number ;
134+ stdout ?: string ;
135+ stderr ?: string ;
136+ /** Emit stdout as these exact byte chunks, to exercise a multi-byte
137+ * character split across a pipe-chunk boundary. */
138+ stdoutChunks ?: Buffer [ ] ;
139+ } ;
132140
133141function createSpawnSpy ( outcomes : SpawnOutcome [ ] ) : {
134142 spawn : ( command : string , args : readonly string [ ] ) => FakeProc ;
@@ -159,7 +167,9 @@ function createSpawnSpy(outcomes: SpawnOutcome[]): {
159167 proc . emit ( "error" , err ) ;
160168 return ;
161169 }
162- if ( outcome . stdout ) proc . stdout . emit ( "data" , Buffer . from ( outcome . stdout ) ) ;
170+ if ( outcome . stdoutChunks ) {
171+ for ( const chunk of outcome . stdoutChunks ) proc . stdout . emit ( "data" , chunk ) ;
172+ } else if ( outcome . stdout ) proc . stdout . emit ( "data" , Buffer . from ( outcome . stdout ) ) ;
163173 if ( outcome . stderr ) proc . stderr . emit ( "data" , Buffer . from ( outcome . stderr ) ) ;
164174 proc . emit ( "close" , outcome . code ) ;
165175 } ) ;
@@ -961,3 +971,72 @@ describe("AAC duration refinement must never fail or distort the call", () => {
961971 expect ( meta . durationSeconds ) . toBeCloseTo ( ( 861 * 1024 ) / 44100 , 5 ) ;
962972 } ) ;
963973} ) ;
974+
975+ describe ( "runFfprobe process and stream handling" , ( ) => {
976+ afterEach ( ( ) => {
977+ vi . resetModules ( ) ;
978+ vi . doUnmock ( "child_process" ) ;
979+ } ) ;
980+
981+ // Regression: `--` protects "-intro.mp4" but not a path of exactly "-",
982+ // which ffprobe rewrites to fd: AFTER option parsing and then reads stdin.
983+ // With stdin left as an unwritten pipe the probe hung for the full 30s
984+ // deadline and failed with an empty diagnostic.
985+ it ( "rejects a filePath of '-' immediately instead of hanging on stdin" , async ( ) => {
986+ const { spawn, calls } = createSpawnSpy ( [ { kind : "exit" , code : 0 , stdout : "{}" } ] ) ;
987+ vi . resetModules ( ) ;
988+ vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
989+ const { extractMediaMetadata } = await import ( "./ffprobe.js" ) ;
990+
991+ await expect ( extractMediaMetadata ( "-" ) ) . rejects . toThrow ( / s t d i n i s n o t a s u p p o r t e d i n p u t p a t h / ) ;
992+ expect ( calls ) . toHaveLength ( 0 ) ;
993+ } ) ;
994+
995+ it ( "never leaves the child's stdin as a writable pipe" , async ( ) => {
996+ const stdios : unknown [ ] = [ ] ;
997+ const spawn = ( _c : string , _a : readonly string [ ] , opts ?: { stdio ?: unknown } ) => {
998+ stdios . push ( opts ?. stdio ) ;
999+ const proc = new EventEmitter ( ) as FakeProc ;
1000+ proc . stdout = new EventEmitter ( ) ;
1001+ proc . stderr = new EventEmitter ( ) ;
1002+ process . nextTick ( ( ) => {
1003+ proc . stdout . emit (
1004+ "data" ,
1005+ Buffer . from (
1006+ JSON . stringify ( {
1007+ streams : [ { codec_type : "video" , codec_name : "h264" , width : 2 , height : 2 } ] ,
1008+ format : { duration : "1" } ,
1009+ } ) ,
1010+ ) ,
1011+ ) ;
1012+ proc . emit ( "close" , 0 ) ;
1013+ } ) ;
1014+ return proc ;
1015+ } ;
1016+ vi . resetModules ( ) ;
1017+ vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
1018+ const { extractMediaMetadata } = await import ( "./ffprobe.js" ) ;
1019+
1020+ await extractMediaMetadata ( "/tmp/stdio-shape.mp4" ) ;
1021+ expect ( stdios [ 0 ] ) . toEqual ( [ "ignore" , "pipe" , "pipe" ] ) ;
1022+ } ) ;
1023+
1024+ // NOTE on the StringDecoder change: a per-chunk toString() corrupts a
1025+ // multi-byte character split across a pipe boundary into U+FFFD, but
1026+ // U+FFFD is valid JSON string content, so JSON.parse still succeeds and
1027+ // extractMediaMetadata's public surface returns nothing that exposes the
1028+ // mangled tag value. There is no assertion here that fails on the old
1029+ // implementation, so rather than ship a test that cannot fail, the
1030+ // corruption is stated in the commit and this covers the bound instead.
1031+ it ( "refuses to parse stdout that exceeds the size bound" , async ( ) => {
1032+ const huge = "x" . repeat ( 8_000_001 ) ;
1033+ const { spawn } = createSpawnSpy ( [ { kind : "exit" , code : 0 , stdout : huge } ] ) ;
1034+ vi . resetModules ( ) ;
1035+ vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
1036+ const { extractMediaMetadata } = await import ( "./ffprobe.js" ) ;
1037+
1038+ await expect ( extractMediaMetadata ( "/tmp/unbounded-output.mov" ) ) . rejects . toThrow (
1039+ / e x c e e d e d 8 0 0 0 0 0 0 c h a r a c t e r s / ,
1040+ ) ;
1041+ } ) ;
1042+ } ) ;
0 commit comments