@@ -349,8 +349,239 @@ fn test_active_threads_reports_correct_count() {
349349 // Hmm, this is tricky. Let me just verify the initial state makes sense.
350350 let max = pool. max_threads ( ) ;
351351 assert_eq ! ( max, 8 ) ;
352- // active_threads = max - available. Available starts at min_threads (3).
353- // So active reports 8 - 3 = 5. But that's "potential active", not "actually working".
354- // The metric is really "permitted concurrency level" not "currently executing".
355- // This is fine for the scaler — it adjusts the permit count, not the work count.
352+ }
353+
354+ // =============================================================================
355+ // Adversarial / edge case tests
356+ // =============================================================================
357+
358+ #[ test]
359+ fn test_process_batch_panic_in_closure_does_not_crash_pool ( ) {
360+ // If a closure panics, rayon propagates it. The pool should still be usable after.
361+ let config = WorkerPoolConfig {
362+ min_threads : 2 ,
363+ max_threads : 2 ,
364+ ..Default :: default ( )
365+ } ;
366+ let pool = AdaptiveWorkerPool :: new ( config) ;
367+
368+ // First batch: one item panics
369+ let items: Vec < i32 > = vec ! [ 1 , 2 , 3 ] ;
370+ let result = std:: panic:: catch_unwind ( std:: panic:: AssertUnwindSafe ( || {
371+ pool. process_batch ( & items, |& item| -> Result < i32 , String > {
372+ if item == 2 {
373+ panic ! ( "deliberate panic in closure" ) ;
374+ }
375+ Ok ( item)
376+ } )
377+ } ) ) ;
378+ assert ! ( result. is_err( ) , "panic should propagate from process_batch" ) ;
379+
380+ // Pool should still be usable after the panic
381+ let items2: Vec < i32 > = vec ! [ 10 , 20 , 30 ] ;
382+ let results: Vec < Result < i32 , String > > = pool. process_batch ( & items2, |& item| Ok ( item * 2 ) ) ;
383+ assert_eq ! ( results. len( ) , 3 ) ;
384+ assert_eq ! ( * results[ 0 ] . as_ref( ) . unwrap( ) , 20 ) ;
385+ assert_eq ! ( * results[ 1 ] . as_ref( ) . unwrap( ) , 40 ) ;
386+ assert_eq ! ( * results[ 2 ] . as_ref( ) . unwrap( ) , 60 ) ;
387+ }
388+
389+ #[ test]
390+ fn test_process_batch_all_errors ( ) {
391+ let pool = AdaptiveWorkerPool :: new ( WorkerPoolConfig :: default ( ) ) ;
392+ let items: Vec < i32 > = ( 0 ..50 ) . collect ( ) ;
393+ let results: Vec < Result < i32 , String > > =
394+ pool. process_batch ( & items, |& item| Err ( format ! ( "every item fails: {item}" ) ) ) ;
395+ assert_eq ! ( results. len( ) , 50 ) ;
396+ assert ! ( results. iter( ) . all( Result :: is_err) ) ;
397+ }
398+
399+ #[ test]
400+ fn test_process_batch_single_item ( ) {
401+ let pool = AdaptiveWorkerPool :: new ( WorkerPoolConfig :: default ( ) ) ;
402+ let items = vec ! [ 42 ] ;
403+ let results: Vec < Result < i32 , String > > = pool. process_batch ( & items, |& x| Ok ( x * 2 ) ) ;
404+ assert_eq ! ( results. len( ) , 1 ) ;
405+ assert_eq ! ( * results[ 0 ] . as_ref( ) . unwrap( ) , 84 ) ;
406+ }
407+
408+ #[ test]
409+ fn test_process_batch_large_batch_stress ( ) {
410+ // 10,000 items should process without issues
411+ let config = WorkerPoolConfig {
412+ min_threads : 4 ,
413+ max_threads : 4 ,
414+ ..Default :: default ( )
415+ } ;
416+ let pool = AdaptiveWorkerPool :: new ( config) ;
417+ let items: Vec < i32 > = ( 0 ..10_000 ) . collect ( ) ;
418+ let results: Vec < Result < i64 , String > > = pool. process_batch ( & items, |& item| {
419+ // Some CPU work to exercise thread scheduling
420+ Ok ( i64:: from ( item) * i64:: from ( item) )
421+ } ) ;
422+ assert_eq ! ( results. len( ) , 10_000 ) ;
423+ // Verify ordering preserved
424+ for ( i, result) in results. iter ( ) . enumerate ( ) {
425+ let expected = ( i as i64 ) * ( i as i64 ) ;
426+ assert_eq ! (
427+ * result. as_ref( ) . unwrap( ) ,
428+ expected,
429+ "ordering broken at index {i}"
430+ ) ;
431+ }
432+ }
433+
434+ #[ test]
435+ fn test_config_validation_grow_equals_shrink_rejected ( ) {
436+ let cfg = WorkerPoolConfig {
437+ grow_below : 0.80 ,
438+ shrink_above : 0.80 , // equal — no dead band
439+ ..Default :: default ( )
440+ } ;
441+ assert ! ( cfg. validate( ) . is_err( ) ) ;
442+ }
443+
444+ #[ test]
445+ fn test_config_validation_emergency_below_shrink_rejected ( ) {
446+ let cfg = WorkerPoolConfig {
447+ shrink_above : 0.95 ,
448+ emergency_above : 0.90 , // below shrink
449+ ..Default :: default ( )
450+ } ;
451+ assert ! ( cfg. validate( ) . is_err( ) ) ;
452+ }
453+
454+ #[ test]
455+ fn test_config_min_threads_zero_rejected_by_rayon ( ) {
456+ // min_threads=0 means no permits — every rayon task would block forever.
457+ // This should still create the pool (rayon allows 0 threads? no, it panics).
458+ // Actually rayon requires at least 1 thread. But our semaphore starts at min_threads.
459+ // With 0 permits, process_batch would deadlock. Validate this edge case.
460+ let config = WorkerPoolConfig {
461+ min_threads : 0 ,
462+ max_threads : 4 ,
463+ ..Default :: default ( )
464+ } ;
465+ // Config validates OK (0 is technically valid — means "start with 0 active, scaler grows")
466+ // But process_batch would block. This is a design choice — min_threads=0 is allowed
467+ // for services that start idle and scale up on demand.
468+ assert ! ( config. validate( ) . is_ok( ) ) ;
469+ }
470+
471+ #[ test]
472+ fn test_scaling_decision_boundary_exactly_at_grow_below ( ) {
473+ // CPU exactly at grow_below threshold — should be in steady band (not grow)
474+ let input = ScalingInput {
475+ cpu_util : 0.60 , // exactly at grow_below
476+ memory_pressure : 0.20 ,
477+ current : 4 ,
478+ min_threads : 2 ,
479+ max_threads : 8 ,
480+ grow_below : 0.60 ,
481+ shrink_above : 0.85 ,
482+ emergency_above : 0.95 ,
483+ memory_pressure_cap : 0.80 ,
484+ } ;
485+ let decision = ScalingDecision :: evaluate ( & input) ;
486+ // At exactly grow_below: cpu_util < grow_below is FALSE (0.60 < 0.60 = false)
487+ // So it falls to cpu_util <= shrink_above (0.60 <= 0.85 = true) → steady
488+ assert_eq ! ( decision. direction, "steady" ) ;
489+ }
490+
491+ #[ test]
492+ fn test_scaling_decision_boundary_exactly_at_shrink_above ( ) {
493+ let input = ScalingInput {
494+ cpu_util : 0.85 , // exactly at shrink_above
495+ memory_pressure : 0.20 ,
496+ current : 4 ,
497+ min_threads : 2 ,
498+ max_threads : 8 ,
499+ grow_below : 0.60 ,
500+ shrink_above : 0.85 ,
501+ emergency_above : 0.95 ,
502+ memory_pressure_cap : 0.80 ,
503+ } ;
504+ let decision = ScalingDecision :: evaluate ( & input) ;
505+ // cpu_util <= shrink_above (0.85 <= 0.85 = true) → steady
506+ assert_eq ! ( decision. direction, "steady" ) ;
507+ }
508+
509+ #[ test]
510+ fn test_scaling_decision_boundary_exactly_at_emergency ( ) {
511+ let input = ScalingInput {
512+ cpu_util : 0.95 , // exactly at emergency_above
513+ memory_pressure : 0.20 ,
514+ current : 4 ,
515+ min_threads : 2 ,
516+ max_threads : 8 ,
517+ grow_below : 0.60 ,
518+ shrink_above : 0.85 ,
519+ emergency_above : 0.95 ,
520+ memory_pressure_cap : 0.80 ,
521+ } ;
522+ let decision = ScalingDecision :: evaluate ( & input) ;
523+ // cpu_util <= emergency_above (0.95 <= 0.95 = true) → down (not emergency)
524+ assert_eq ! ( decision. direction, "down" ) ;
525+ }
526+
527+ #[ test]
528+ fn test_scaling_decision_memory_exactly_at_cap ( ) {
529+ let input = ScalingInput {
530+ cpu_util : 0.40 ,
531+ memory_pressure : 0.80 , // exactly at memory_pressure_cap
532+ current : 6 ,
533+ min_threads : 2 ,
534+ max_threads : 8 ,
535+ grow_below : 0.60 ,
536+ shrink_above : 0.85 ,
537+ emergency_above : 0.95 ,
538+ memory_pressure_cap : 0.80 ,
539+ } ;
540+ let decision = ScalingDecision :: evaluate ( & input) ;
541+ // memory_pressure > memory_pressure_cap (0.80 > 0.80 = false) → NOT memory_cap
542+ // Falls through to cpu check: 0.40 < 0.60 → grow
543+ assert_eq ! ( decision. direction, "up" ) ;
544+ }
545+
546+ #[ tokio:: test]
547+ async fn test_fan_out_async_with_all_failures ( ) {
548+ let pool = AdaptiveWorkerPool :: new ( WorkerPoolConfig :: default ( ) ) ;
549+ let items: Vec < i32 > = ( 0 ..10 ) . collect ( ) ;
550+ let results: Vec < Result < i32 , String > > = pool
551+ . fan_out_async ( & items, |& item| async move { Err ( format ! ( "fail: {item}" ) ) } )
552+ . await ;
553+ assert_eq ! ( results. len( ) , 10 ) ;
554+ assert ! ( results. iter( ) . all( Result :: is_err) ) ;
555+ }
556+
557+ #[ tokio:: test]
558+ async fn test_fan_out_async_mixed_success_failure_preserves_order ( ) {
559+ let pool = AdaptiveWorkerPool :: new ( WorkerPoolConfig {
560+ async_concurrency : 4 ,
561+ ..Default :: default ( )
562+ } ) ;
563+ let items: Vec < i32 > = ( 0 ..20 ) . collect ( ) ;
564+ let results: Vec < Result < i32 , String > > = pool
565+ . fan_out_async ( & items, |& item| async move {
566+ // Variable delay to stress ordering
567+ tokio:: time:: sleep ( std:: time:: Duration :: from_millis (
568+ u64:: try_from ( item % 5 ) . unwrap_or ( 0 ) ,
569+ ) )
570+ . await ;
571+ if item % 3 == 0 {
572+ Err ( format ! ( "fail: {item}" ) )
573+ } else {
574+ Ok ( item * 10 )
575+ }
576+ } )
577+ . await ;
578+
579+ assert_eq ! ( results. len( ) , 20 ) ;
580+ // Verify ordering: even indices that are multiples of 3 should be errors
581+ assert ! ( results[ 0 ] . is_err( ) ) ; // 0 % 3 == 0
582+ assert ! ( results[ 3 ] . is_err( ) ) ; // 3 % 3 == 0
583+ assert ! ( results[ 6 ] . is_err( ) ) ; // 6 % 3 == 0
584+ assert_eq ! ( * results[ 1 ] . as_ref( ) . unwrap( ) , 10 ) ; // 1 * 10
585+ assert_eq ! ( * results[ 2 ] . as_ref( ) . unwrap( ) , 20 ) ; // 2 * 10
586+ assert_eq ! ( * results[ 4 ] . as_ref( ) . unwrap( ) , 40 ) ; // 4 * 10
356587}
0 commit comments