@@ -307,10 +307,17 @@ mod tests {
307307 use flate2:: Compression ;
308308 use flate2:: write:: GzEncoder ;
309309 use mockito:: Server ;
310+ use std:: env;
310311 use std:: io:: Write ;
312+ use std:: path:: PathBuf ;
313+ use std:: process:: { Child , Command } ;
314+ use std:: sync:: Arc ;
315+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
316+ use std:: time:: { Duration , Instant } ;
311317 use tar:: Builder ;
312318 use tempfile:: TempDir ;
313319 use tokio:: task;
320+ use tokio:: time:: sleep;
314321
315322 // Helper to create a simple tar.gz archive in memory
316323 fn create_tar_gz ( ) -> Vec < u8 > {
@@ -330,6 +337,80 @@ mod tests {
330337 encoder. finish ( ) . unwrap ( )
331338 }
332339
340+ fn create_package_tar_gz ( ) -> ( Vec < u8 > , String ) {
341+ let package_json = r#"{"name":"race-pkg","version":"1.0.0"}"# . to_string ( ) ;
342+ let mut tar_data = Vec :: new ( ) ;
343+ {
344+ let mut tar = Builder :: new ( & mut tar_data) ;
345+ append_tar_file ( & mut tar, "package/package.json" , package_json. as_bytes ( ) ) ;
346+
347+ for index in 0 ..128 {
348+ let content = format ! (
349+ "file-{index}\n {}" ,
350+ "0123456789abcdefghijklmnopqrstuvwxyz" . repeat( 256 )
351+ ) ;
352+ append_tar_file (
353+ & mut tar,
354+ & format ! ( "package/lib/file-{index}.txt" ) ,
355+ content. as_bytes ( ) ,
356+ ) ;
357+ }
358+
359+ tar. finish ( ) . unwrap ( ) ;
360+ }
361+
362+ let mut encoder = GzEncoder :: new ( Vec :: new ( ) , Compression :: default ( ) ) ;
363+ encoder. write_all ( & tar_data) . unwrap ( ) ;
364+ ( encoder. finish ( ) . unwrap ( ) , package_json)
365+ }
366+
367+ fn create_large_package_json_tar_gz ( ) -> ( Vec < u8 > , usize ) {
368+ let package_json = format ! (
369+ r#"{{"name":"race-pkg","version":"1.0.0","padding":"{}"}}"# ,
370+ "x" . repeat( 64 * 1024 * 1024 )
371+ ) ;
372+ let package_json_len = package_json. len ( ) ;
373+ let mut tar_data = Vec :: new ( ) ;
374+ {
375+ let mut tar = Builder :: new ( & mut tar_data) ;
376+ append_tar_file ( & mut tar, "package/package.json" , package_json. as_bytes ( ) ) ;
377+ tar. finish ( ) . unwrap ( ) ;
378+ }
379+
380+ let mut encoder = GzEncoder :: new ( Vec :: new ( ) , Compression :: default ( ) ) ;
381+ encoder. write_all ( & tar_data) . unwrap ( ) ;
382+ ( encoder. finish ( ) . unwrap ( ) , package_json_len)
383+ }
384+
385+ fn append_tar_file ( tar : & mut Builder < & mut Vec < u8 > > , path : & str , content : & [ u8 ] ) {
386+ let mut header = tar:: Header :: new_gnu ( ) ;
387+ header. set_path ( path) . unwrap ( ) ;
388+ header. set_size ( content. len ( ) as u64 ) ;
389+ header. set_mode ( 0o644 ) ;
390+ header. set_cksum ( ) ;
391+ tar. append ( & header, content) . unwrap ( ) ;
392+ }
393+
394+ fn spawn_gated_download_helper (
395+ test_binary : & Path ,
396+ url : & str ,
397+ dest : & Path ,
398+ ready_dir : & Path ,
399+ start_file : & Path ,
400+ ) -> Child {
401+ Command :: new ( test_binary)
402+ . arg ( "util::downloader::tests::download_process_helper" )
403+ . arg ( "--ignored" )
404+ . arg ( "--nocapture" )
405+ . env ( "UTOO_DOWNLOAD_HELPER" , "1" )
406+ . env ( "UTOO_DOWNLOAD_HELPER_URL" , url)
407+ . env ( "UTOO_DOWNLOAD_HELPER_DEST" , dest)
408+ . env ( "UTOO_DOWNLOAD_HELPER_READY_DIR" , ready_dir)
409+ . env ( "UTOO_DOWNLOAD_HELPER_START_FILE" , start_file)
410+ . spawn ( )
411+ . unwrap ( )
412+ }
413+
333414 #[ tokio:: test]
334415 async fn test_download_idempotent ( ) {
335416 let tar_gz = create_tar_gz ( ) ;
@@ -369,4 +450,229 @@ mod tests {
369450 assert_eq ! ( content, "hello world" ) ;
370451 _m. assert ( ) ;
371452 }
453+
454+ #[ tokio:: test]
455+ #[ ignore]
456+ async fn test_download_shared_dest_across_processes ( ) {
457+ let ( tar_gz, package_json) = create_package_tar_gz ( ) ;
458+ let body = Arc :: new ( tar_gz) ;
459+ let mut server = Server :: new_async ( ) . await ;
460+ let child_count = 8 ;
461+ let _m = server
462+ . mock ( "GET" , "/pkg.tgz" )
463+ . with_status ( 200 )
464+ . with_header ( "content-type" , "application/gzip" )
465+ . with_chunked_body ( move |writer| {
466+ std:: thread:: sleep ( Duration :: from_millis ( 200 ) ) ;
467+ for chunk in body. chunks ( 1024 ) {
468+ writer. write_all ( chunk) ?;
469+ std:: thread:: sleep ( Duration :: from_millis ( 2 ) ) ;
470+ }
471+ Ok ( ( ) )
472+ } )
473+ . expect_at_least ( 2 )
474+ . expect_at_most ( child_count)
475+ . create_async ( )
476+ . await ;
477+
478+ let url = format ! ( "{}/pkg.tgz" , server. url( ) ) ;
479+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
480+ let dest = temp_dir. path ( ) . join ( "pkg" ) ;
481+ let ready_dir = temp_dir. path ( ) . join ( "ready" ) ;
482+ let start_file = temp_dir. path ( ) . join ( "start" ) ;
483+ crate :: fs:: create_dir_all ( & ready_dir) . await . unwrap ( ) ;
484+ let test_binary = env:: current_exe ( ) . unwrap ( ) ;
485+ let mut children = Vec :: new ( ) ;
486+
487+ for _ in 0 ..child_count {
488+ children. push ( spawn_gated_download_helper (
489+ & test_binary,
490+ & url,
491+ & dest,
492+ & ready_dir,
493+ & start_file,
494+ ) ) ;
495+ }
496+
497+ wait_for_ready_files ( & ready_dir, child_count, Duration :: from_secs ( 5 ) ) . await ;
498+ crate :: fs:: write ( & start_file, "" ) . await . unwrap ( ) ;
499+
500+ let deadline = Instant :: now ( ) + Duration :: from_secs ( 20 ) ;
501+ let mut failures = Vec :: new ( ) ;
502+ for mut child in children {
503+ loop {
504+ match child. try_wait ( ) . unwrap ( ) {
505+ Some ( status) => {
506+ if !status. success ( ) {
507+ failures. push ( status. to_string ( ) ) ;
508+ }
509+ break ;
510+ }
511+ None if Instant :: now ( ) >= deadline => {
512+ let _ = child. kill ( ) ;
513+ failures. push ( "timed out" . to_string ( ) ) ;
514+ break ;
515+ }
516+ None => sleep ( Duration :: from_millis ( 25 ) ) . await ,
517+ }
518+ }
519+ }
520+
521+ assert ! ( failures. is_empty( ) , "download helpers failed: {failures:?}" ) ;
522+ assert ! ( dest. join( "_resolved" ) . exists( ) ) ;
523+
524+ let content = crate :: fs:: read_to_string ( dest. join ( "package/package.json" ) )
525+ . await
526+ . unwrap ( ) ;
527+ assert_eq ! ( content, package_json) ;
528+
529+ for index in [ 0 , 31 , 63 , 127 ] {
530+ let path = dest. join ( format ! ( "package/lib/file-{index}.txt" ) ) ;
531+ let metadata = crate :: fs:: metadata ( & path) . await . unwrap ( ) ;
532+ assert ! ( metadata. len( ) > 0 , "{} was empty" , path. display( ) ) ;
533+ }
534+
535+ _m. assert ( ) ;
536+ }
537+
538+ #[ tokio:: test]
539+ #[ ignore]
540+ async fn repro_download_can_leave_zero_file_if_racing_process_dies_after_resolved ( ) {
541+ let ( tar_gz, expected_package_json_len) = create_large_package_json_tar_gz ( ) ;
542+ let body = Arc :: new ( tar_gz) ;
543+ let request_index = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
544+ let mut server = Server :: new_async ( ) . await ;
545+ let _m = server
546+ . mock ( "GET" , "/pkg.tgz" )
547+ . with_status ( 200 )
548+ . with_header ( "content-type" , "application/gzip" )
549+ . with_chunked_body ( move |writer| {
550+ let index = request_index. fetch_add ( 1 , Ordering :: SeqCst ) ;
551+ if index == 1 {
552+ std:: thread:: sleep ( Duration :: from_millis ( 500 ) ) ;
553+ }
554+
555+ for chunk in body. chunks ( 64 * 1024 ) {
556+ writer. write_all ( chunk) ?;
557+ }
558+ Ok ( ( ) )
559+ } )
560+ . expect ( 2 )
561+ . create_async ( )
562+ . await ;
563+
564+ let url = format ! ( "{}/pkg.tgz" , server. url( ) ) ;
565+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
566+ let dest = temp_dir. path ( ) . join ( "pkg" ) ;
567+ let package_json_path = dest. join ( "package/package.json" ) ;
568+ let ready_dir = temp_dir. path ( ) . join ( "ready" ) ;
569+ let start_file = temp_dir. path ( ) . join ( "start" ) ;
570+ crate :: fs:: create_dir_all ( & ready_dir) . await . unwrap ( ) ;
571+ let test_binary = env:: current_exe ( ) . unwrap ( ) ;
572+ let children = vec ! [
573+ spawn_gated_download_helper( & test_binary, & url, & dest, & ready_dir, & start_file) ,
574+ spawn_gated_download_helper( & test_binary, & url, & dest, & ready_dir, & start_file) ,
575+ ] ;
576+
577+ wait_for_ready_files ( & ready_dir, 2 , Duration :: from_secs ( 5 ) ) . await ;
578+ crate :: fs:: write ( & start_file, "" ) . await . unwrap ( ) ;
579+
580+ wait_for_path ( & dest. join ( "_resolved" ) , Duration :: from_secs ( 5 ) ) . await ;
581+ wait_for_zero_len ( & package_json_path, Duration :: from_secs ( 15 ) ) . await ;
582+
583+ for child in & children {
584+ if child. id ( ) != 0 {
585+ let _ = Command :: new ( "kill" )
586+ . arg ( "-KILL" )
587+ . arg ( child. id ( ) . to_string ( ) )
588+ . status ( ) ;
589+ }
590+ }
591+
592+ for mut child in children {
593+ let _ = child. wait ( ) ;
594+ }
595+
596+ assert ! ( dest. join( "_resolved" ) . exists( ) ) ;
597+ assert_eq ! (
598+ crate :: fs:: metadata( & package_json_path) . await . unwrap( ) . len( ) ,
599+ 0 ,
600+ "racing writer finished instead of being killed after truncating package.json; expected full size was {expected_package_json_len}"
601+ ) ;
602+
603+ _m. assert ( ) ;
604+ }
605+
606+ async fn wait_for_path ( path : & Path , timeout : Duration ) {
607+ let deadline = Instant :: now ( ) + timeout;
608+ while Instant :: now ( ) < deadline {
609+ if path. exists ( ) {
610+ return ;
611+ }
612+ sleep ( Duration :: from_millis ( 10 ) ) . await ;
613+ }
614+ panic ! ( "timed out waiting for {}" , path. display( ) ) ;
615+ }
616+
617+ async fn wait_for_ready_files ( path : & Path , count : usize , timeout : Duration ) {
618+ let deadline = Instant :: now ( ) + timeout;
619+ while Instant :: now ( ) < deadline {
620+ let ready_count = std:: fs:: read_dir ( path)
621+ . map ( |entries| entries. count ( ) )
622+ . unwrap_or ( 0 ) ;
623+ if ready_count >= count {
624+ return ;
625+ }
626+ sleep ( Duration :: from_millis ( 10 ) ) . await ;
627+ }
628+ panic ! (
629+ "timed out waiting for {count} ready files in {}" ,
630+ path. display( )
631+ ) ;
632+ }
633+
634+ async fn wait_for_zero_len ( path : & Path , timeout : Duration ) {
635+ let deadline = Instant :: now ( ) + timeout;
636+ while Instant :: now ( ) < deadline {
637+ if let Ok ( metadata) = crate :: fs:: metadata ( path) . await
638+ && metadata. len ( ) == 0
639+ {
640+ return ;
641+ }
642+ sleep ( Duration :: from_millis ( 10 ) ) . await ;
643+ }
644+ panic ! ( "timed out waiting for zero-length {}" , path. display( ) ) ;
645+ }
646+
647+ #[ test]
648+ #[ ignore]
649+ fn download_process_helper ( ) {
650+ if env:: var ( "UTOO_DOWNLOAD_HELPER" ) . ok ( ) . as_deref ( ) != Some ( "1" ) {
651+ return ;
652+ }
653+
654+ let url = env:: var ( "UTOO_DOWNLOAD_HELPER_URL" ) . unwrap ( ) ;
655+ let dest = PathBuf :: from ( env:: var ( "UTOO_DOWNLOAD_HELPER_DEST" ) . unwrap ( ) ) ;
656+ if let Ok ( ready_dir) = env:: var ( "UTOO_DOWNLOAD_HELPER_READY_DIR" ) {
657+ std:: fs:: create_dir_all ( & ready_dir) . unwrap ( ) ;
658+ std:: fs:: write (
659+ PathBuf :: from ( ready_dir) . join ( std:: process:: id ( ) . to_string ( ) ) ,
660+ b"ready" ,
661+ )
662+ . unwrap ( ) ;
663+
664+ let start_file = PathBuf :: from ( env:: var ( "UTOO_DOWNLOAD_HELPER_START_FILE" ) . unwrap ( ) ) ;
665+ while !start_file. exists ( ) {
666+ std:: thread:: sleep ( Duration :: from_millis ( 10 ) ) ;
667+ }
668+ }
669+ let runtime = tokio:: runtime:: Builder :: new_multi_thread ( )
670+ . enable_all ( )
671+ . build ( )
672+ . unwrap ( ) ;
673+
674+ runtime. block_on ( async {
675+ download ( & url, & dest) . await . unwrap ( ) ;
676+ } ) ;
677+ }
372678}
0 commit comments