11# typed: strict
22# frozen_string_literal: true
33
4+ require "json"
5+
46module Homebrew
57 module TestBot
68 class FormulaeDependents < TestFormulae
9+ DependentFootprint = T . type_alias { T ::Set [ String ] }
10+ class ParallelDependentTest < T ::Struct
11+ const :pid , Integer
12+ const :dependent_name , String
13+ const :lock_set , DependentFootprint
14+ const :result_reader , IO
15+ const :ignore_failures , T ::Boolean
16+ end
17+
18+ MAX_PARALLEL_DEPENDENT_TESTS = 2
19+
720 sig { params ( testing_formulae : T ::Array [ String ] ) . returns ( T ::Array [ String ] ) }
821 attr_writer :testing_formulae
922
@@ -24,6 +37,7 @@ def initialize(tap:, git:, dry_run:, fail_fast:, verbose:)
2437 @testing_formulae_with_tested_dependents = T . let ( [ ] , T ::Array [ String ] )
2538 @tested_dependents_list = T . let ( nil , T . nilable ( Pathname ) )
2639 @dependent_testing_formulae = T . let ( [ ] , T ::Array [ String ] )
40+ @prepared_dependency_formulae = T . let ( Set . new , T ::Set [ String ] )
2741 end
2842
2943 sig { params ( args : Homebrew ::Cmd ::TestBotCmd ::Args ) . void }
@@ -142,8 +156,12 @@ def dependent_formulae!(formula_name, args:)
142156 install_dependent ( dependent , testable_dependents , args :) if bottled? ( dependent )
143157 end
144158
145- bottled_dependents . each do |dependent |
146- install_dependent ( dependent , testable_dependents , args :)
159+ if bottled_dependents . one?
160+ bottled_dependents . each do |dependent |
161+ install_dependent ( dependent , testable_dependents , args :)
162+ end
163+ else
164+ pipeline_bottled_dependents! ( bottled_dependents , testable_dependents , args :)
147165 end
148166 end
149167
@@ -256,15 +274,185 @@ def dependents_for_formula(formula, formula_name, args:)
256274 [ source_dependents , bottled_dependents , testable_dependents ]
257275 end
258276
277+ sig {
278+ params ( bottled_dependents : T ::Array [ Formula ] ,
279+ testable_dependents : T ::Array [ Formula ] , args : Homebrew ::Cmd ::TestBotCmd ::Args ) . void
280+ }
281+ def pipeline_bottled_dependents! ( bottled_dependents , testable_dependents , args :)
282+ info_header "Running bottled dependents:"
283+ puts bottled_dependents
284+
285+ dependent_dependency_sets = T . let (
286+ bottled_dependents . to_h { |dependent | [ dependent , dependent_dependencies ( dependent , testable_dependents ) ] } ,
287+ T ::Hash [ Formula , T ::Array [ Dependency ] ] ,
288+ )
289+ lock_sets = T . let (
290+ bottled_dependents . to_h do |dependent |
291+ [ dependent , dependent_lock_set ( dependent , dependent_dependency_sets . fetch ( dependent ) ) ]
292+ end ,
293+ T ::Hash [ Formula , DependentFootprint ] ,
294+ )
295+ active_tests = T . let ( { } , T ::Hash [ Integer , ParallelDependentTest ] )
296+ pending_dependents = T . let (
297+ bottled_dependents . sort_by do |dependent |
298+ [ testable_dependents . include? ( dependent ) ? 0 : 1 ,
299+ dependent_mutation_set ( dependent , dependent_dependency_sets . fetch ( dependent ) ) . length ]
300+ end ,
301+ T ::Array [ Formula ] ,
302+ )
303+
304+ until pending_dependents . blank?
305+ dependent = pending_dependents . find do |candidate |
306+ mutation_set = dependent_mutation_set ( candidate , dependent_dependency_sets . fetch ( candidate ) )
307+ active_tests . values . none? do |worker |
308+ worker . lock_set . intersect? ( mutation_set )
309+ end
310+ end
311+
312+ unless dependent
313+ wait_for_parallel_dependent_test ( active_tests )
314+ next
315+ end
316+
317+ pending_dependents . delete ( dependent )
318+
319+ worker = install_dependent (
320+ dependent ,
321+ testable_dependents ,
322+ dependency_closure : dependent_dependency_sets . fetch ( dependent ) ,
323+ lock_set : lock_sets . fetch ( dependent ) ,
324+ parallel_test : true ,
325+ args :,
326+ )
327+ next if worker . nil?
328+
329+ puts "Launching parallel test for #{ dependent . full_name } "
330+ active_tests [ worker . pid ] = worker
331+ wait_for_parallel_dependent_test ( active_tests ) while active_tests . length >= MAX_PARALLEL_DEPENDENT_TESTS
332+ end
333+
334+ wait_for_parallel_dependent_test ( active_tests ) until active_tests . blank?
335+ end
336+
337+ sig { params ( active_tests : T ::Hash [ Integer , ParallelDependentTest ] ) . void }
338+ def wait_for_parallel_dependent_test ( active_tests )
339+ pid , = Process . wait2
340+ worker = active_tests . delete ( pid )
341+ return if worker . nil?
342+
343+ result_reader = worker . result_reader
344+ result = T . let ( JSON . parse ( result_reader . read ) , T ::Hash [ String , T . untyped ] )
345+ result_reader . close
346+
347+ status = T . cast ( result . fetch ( "status" ) , String )
348+ step = Step . new (
349+ [ "brew" , "test" , "--retry" , "--verbose" ] ,
350+ named_args : worker . dependent_name ,
351+ env : { } ,
352+ verbose : false ,
353+ ignore_failures : worker . ignore_failures ,
354+ repository :,
355+ )
356+ step . instance_variable_set ( :@status , status . to_sym )
357+ step . instance_variable_set ( :@start_time , Time . at ( T . cast ( result . fetch ( "start_time" ) , Float ) ) )
358+ step . instance_variable_set ( :@end_time , Time . at ( T . cast ( result . fetch ( "end_time" ) , Float ) ) )
359+ steps << step
360+
361+ test "brew" , "uninstall" , "--force" , "--ignore-dependencies" , worker . dependent_name
362+
363+ if status == "passed"
364+ @tested_dependents_list &.write ( worker . dependent_name , mode : "a" )
365+ @tested_dependents_list &.write ( "\n " , mode : "a" )
366+ end
367+
368+ exit 1 if @fail_fast && status == "failed"
369+ end
370+
371+ sig {
372+ params (
373+ dependent : Formula ,
374+ env : T ::Hash [ String , String ] ,
375+ ignore_failures : T ::Boolean ,
376+ lock_set : DependentFootprint ,
377+ ) . returns ( ParallelDependentTest )
378+ }
379+ def launch_parallel_dependent_test ( dependent , env :, ignore_failures :, lock_set :)
380+ result_reader , result_writer = IO . pipe
381+ pid = fork do
382+ result_reader . close
383+
384+ # The parent is responsible for stopping the queue; the child must always report back.
385+ original_fail_fast = @fail_fast
386+ @fail_fast = false
387+ test_header ( :FormulaeDependents , method : "parallel_test(#{ dependent . full_name } )" )
388+ test "brew" , "test" , "--retry" , "--verbose" ,
389+ named_args : dependent . full_name ,
390+ env :,
391+ ignore_failures : ignore_failures
392+ step = steps . fetch ( -1 )
393+ result_writer . write ( JSON . generate (
394+ status : step . status . to_s ,
395+ start_time : T . must ( step . start_time ) . to_f ,
396+ end_time : T . must ( step . end_time ) . to_f ,
397+ ) )
398+ # The child must always report back to the parent, even on unexpected exceptions.
399+ rescue Exception => e # rubocop:disable Lint/RescueException
400+ $stderr. puts e . full_message ( highlight : false , order : :top )
401+ result_writer . write ( JSON . generate ( status : "failed" , start_time : Time . now . to_f , end_time : Time . now . to_f ) )
402+ ensure
403+ @fail_fast = T . must ( original_fail_fast )
404+ result_writer . close unless result_writer . closed?
405+ exit!
406+ end
407+
408+ result_writer . close
409+
410+ ParallelDependentTest . new (
411+ pid :,
412+ dependent_name : dependent . full_name ,
413+ lock_set :,
414+ result_reader :,
415+ ignore_failures :,
416+ )
417+ end
418+
419+ sig { params ( formula : Formula , dependencies : T ::Array [ Dependency ] ) . returns ( DependentFootprint ) }
420+ def dependent_lock_set ( formula , dependencies )
421+ dependency_formulae = dependencies . map ( &:to_formula )
422+ recursive_formulae = [ formula , *dependency_formulae ] . flat_map do |dep_formula |
423+ [ dep_formula , *recursive_dependency_formulae ( dep_formula ) ]
424+ end
425+
426+ Set . new ( recursive_formulae . uniq ( &:full_name ) . map ( &:full_name ) )
427+ end
428+
429+ sig { params ( formula : Formula , dependencies : T ::Array [ Dependency ] ) . returns ( DependentFootprint ) }
430+ def dependent_mutation_set ( formula , dependencies )
431+ dependency_formulae = dependencies . filter_map do |dependency |
432+ dependency_formula = dependency . to_formula
433+ next if @prepared_dependency_formulae . include? ( dependency_formula . name )
434+ next if dependency . satisfied? && ( dependency_formula . keg_only? || dependency_formula . linked? )
435+
436+ dependency_formula . full_name
437+ end
438+
439+ Set . new ( [ formula . full_name , *dependency_formulae ] )
440+ end
441+
259442 sig {
260443 params (
261444 dependent : Formula ,
262445 testable_dependents : T ::Array [ Formula ] ,
263446 args : Homebrew ::Cmd ::TestBotCmd ::Args ,
264447 build_from_source : T ::Boolean ,
265- ) . void
448+ dependency_closure : T . nilable ( T ::Array [ Dependency ] ) ,
449+ lock_set : T . nilable ( DependentFootprint ) ,
450+ parallel_test : T ::Boolean ,
451+ ) . returns ( T . nilable ( ParallelDependentTest ) )
266452 }
267- def install_dependent ( dependent , testable_dependents , args :, build_from_source : false )
453+ def install_dependent ( dependent , testable_dependents , args :, build_from_source : false , dependency_closure : nil ,
454+ lock_set : nil ,
455+ parallel_test : false )
268456 if @skip_candidates &.include? ( dependent . full_name ) &&
269457 artifact_cache_valid? ( dependent , formulae_dependents : true )
270458 @tested_dependents_list &.write ( dependent . full_name , mode : "a" )
@@ -290,23 +478,13 @@ def install_dependent(dependent, testable_dependents, args:, build_from_source:
290478 bottled_on_current_version = bottled? ( dependent , no_older_versions : true )
291479 dependent_was_previously_installed = dependent . latest_version_installed?
292480
293- dependent_dependencies = Dependency . expand (
294- dependent ,
295- cache_key : "test-bot-dependent-dependencies-#{ dependent . full_name } " ,
296- ) do |dep_dependent , dependency |
297- next if !dependency . build? && !dependency . test? && !dependency . optional?
298- next if dependency . test? &&
299- dep_dependent == dependent &&
300- !dependency . optional? &&
301- testable_dependents . include? ( dependent )
302-
303- Dependency . prune
304- end
481+ dependency_closure ||= dependent_dependencies ( dependent , testable_dependents )
482+ missing_dependencies = missing_dependency_names ( dependency_closure )
305483
306484 unless dependent_was_previously_installed
307485 build_args = [ ]
308486
309- fetch_formulae = dependent_dependencies . reject ( & :satisfied? ) . map ( & :name )
487+ fetch_formulae = missing_dependencies . dup
310488
311489 if build_from_source
312490 required_dependent_reqs = dependent . requirements . reject ( &:optional? )
@@ -329,10 +507,11 @@ def install_dependent(dependent, testable_dependents, args:, build_from_source:
329507
330508 unlink_conflicts dependent
331509
332- test "brew" , "install" , *build_args , "--only-dependencies" ,
333- named_args : dependent . full_name ,
334- ignore_failures : !bottled_on_current_version ,
335- env : { "HOMEBREW_DEVELOPER" => nil }
510+ install_missing_dependency_formulae (
511+ missing_dependencies ,
512+ build_args :,
513+ ignore_failures : !bottled_on_current_version ,
514+ )
336515
337516 env = { }
338517 env [ "HOMEBREW_GIT_PATH" ] = nil if build_from_source && required_dependent_deps . any? do |d |
@@ -352,7 +531,14 @@ def install_dependent(dependent, testable_dependents, args:, build_from_source:
352531 unlink_conflicts dependent
353532 test "brew" , "link" , dependent . full_name
354533 end
355- test "brew" , "install" , "--only-dependencies" , dependent . full_name
534+
535+ # Install the exact missing closure once; for testable dependents this
536+ # already includes direct test deps and their required recursive deps.
537+ install_missing_dependency_formulae (
538+ missing_dependency_names ( dependency_closure ) ,
539+ ignore_failures : !bottled_on_current_version ,
540+ )
541+
356542 test "brew" , "linkage" , "--test" ,
357543 named_args : dependent . full_name ,
358544 ignore_failures : !args . test_default_formula? && !bottled_on_current_version
@@ -367,9 +553,7 @@ def install_dependent(dependent, testable_dependents, args:, build_from_source:
367553 end
368554
369555 if testable_dependents . include? dependent
370- test "brew" , "install" , "--only-dependencies" , "--include-test" , dependent . full_name
371-
372- dependent_dependencies . each do |dependency |
556+ dependency_closure . each do |dependency |
373557 dependency_f = dependency . to_formula
374558 next if dependency_f . keg_only?
375559 next if dependency_f . linked?
@@ -382,6 +566,15 @@ def install_dependent(dependent, testable_dependents, args:, build_from_source:
382566 env [ "HOMEBREW_GIT_PATH" ] = nil if required_dependent_deps . any? do |d |
383567 d . name == "git" && ( !d . build? || d . test? )
384568 end
569+ if parallel_test
570+ return launch_parallel_dependent_test (
571+ dependent ,
572+ env :,
573+ ignore_failures : !args . test_default_formula? && !bottled_on_current_version ,
574+ lock_set : T . must ( lock_set ) ,
575+ )
576+ end
577+
385578 test "brew" , "test" , "--retry" , "--verbose" ,
386579 named_args : dependent . full_name ,
387580 env :,
@@ -426,6 +619,58 @@ def install_dependent(dependent, testable_dependents, args:, build_from_source:
426619 title : "#{ dependent } should be bottled for #{ os_string } !" ,
427620 )
428621 end
622+
623+ nil
624+ end
625+
626+ sig { params ( dependencies : T ::Array [ Dependency ] ) . returns ( T ::Array [ String ] ) }
627+ def missing_dependency_names ( dependencies )
628+ dependencies . reject ( &:satisfied? ) . map ( &:name ) . reject do |formula_name |
629+ @prepared_dependency_formulae . include? ( formula_name )
630+ end . uniq . sort
631+ end
632+
633+ sig {
634+ params (
635+ formula_names : T ::Array [ String ] ,
636+ ignore_failures : T ::Boolean ,
637+ build_args : T ::Array [ String ] ,
638+ ) . void
639+ }
640+ def install_missing_dependency_formulae ( formula_names , ignore_failures :, build_args : [ ] )
641+ return if formula_names . blank?
642+
643+ test "brew" , "install" , *build_args , "--formula" , *formula_names ,
644+ ignore_failures :,
645+ env : { "HOMEBREW_DEVELOPER" => nil }
646+ @prepared_dependency_formulae . merge ( formula_names ) if steps . fetch ( -1 ) . passed?
647+ end
648+
649+ sig { params ( dependent : Formula , testable_dependents : T ::Array [ Formula ] ) . returns ( T ::Array [ Dependency ] ) }
650+ def dependent_dependencies ( dependent , testable_dependents )
651+ Dependency . expand (
652+ dependent ,
653+ cache_key : "test-bot-dependent-dependencies-#{ dependent . full_name } " ,
654+ ) do |dep_dependent , dependency |
655+ next if !dependency . build? && !dependency . test? && !dependency . optional?
656+ next if dependency . test? &&
657+ dep_dependent == dependent &&
658+ !dependency . optional? &&
659+ testable_dependents . include? ( dependent )
660+
661+ Dependency . prune
662+ end
663+ end
664+
665+ sig { params ( formula : Formula ) . returns ( T ::Array [ Formula ] ) }
666+ def recursive_dependency_formulae ( formula )
667+ formula . recursive_dependencies . reject ( &:optional? ) . map ( &:to_formula )
668+ rescue TapFormulaUnavailableError => e
669+ raise if e . tap . installed?
670+
671+ e . tap . clear_cache
672+ safe_system "brew" , "tap" , e . tap . name
673+ retry
429674 end
430675
431676 sig { params ( formula : Formula ) . void }
0 commit comments