From e175fe5138e6480391087986547a864423dd8cdb Mon Sep 17 00:00:00 2001 From: Jhonas Date: Wed, 29 Jan 2025 15:29:48 -0300 Subject: [PATCH 1/6] chore: ensure some exceptions refer to a single task This adapts the exceptions that previously refered with multiple tasks to the error capture system, that requires each exception to refer to a single task. The error messages were adapted for a single task context, but unfortunately they can still a bit too verbose. --- lib/syskit/exceptions.rb | 254 +++++++++--------- .../test_system_network_deployer.rb | 28 +- .../test_system_network_generator.rb | 6 +- .../test_apply_requirement_modifications.rb | 14 +- test/test/test_network_manipulation.rb | 5 +- test/test/test_profile_assertions.rb | 6 +- 6 files changed, 159 insertions(+), 154 deletions(-) diff --git a/lib/syskit/exceptions.rb b/lib/syskit/exceptions.rb index c92324fd6..fe8298d79 100644 --- a/lib/syskit/exceptions.rb +++ b/lib/syskit/exceptions.rb @@ -353,22 +353,20 @@ def pretty_print(pp) # Exception raised when we could not find devices to allocate for tasks # that are device drivers class DeviceAllocationFailed < SpecError - # The set of tasks that failed allocation - attr_reader :failed_tasks + # The task that failed allocation + attr_reader :failed_task # A task to parents mapping for tasks involved in this error, at the # time of the exception creation attr_reader :task_parents # Existing candidates for this device attr_reader :candidates - def initialize(plan, tasks) - @failed_tasks = tasks.dup + def initialize(plan, task) + @failed_task = task @candidates = {} @task_parents = {} - tasks.each do |abstract_task| - resolve_device_task(plan, abstract_task) - end + resolve_device_task(plan, task) end def resolve_device_task(plan, abstract_task) @@ -383,7 +381,7 @@ def resolve_device_task(plan, abstract_task) all_tasks |= candidates[srv] end end - self.candidates[abstract_task] = candidates + @candidates = candidates all_tasks.each do |t| next if task_parents.key?(t) @@ -399,35 +397,33 @@ def resolve_device_task(plan, abstract_task) end def pretty_print(pp) - pp.text "cannot find a device to tie to #{failed_tasks.size} task(s)" + pp.text "cannot find a device to tie to a task" - failed_tasks.each do |task| - parents = task_parents[task] - candidates = self.candidates[task] + parents = task_parents[failed_task] + candidates = self.candidates - pp.breakable - pp.text "for #{task.to_s.gsub('Syskit::', '')}" - pp.nest(2) do - unless parents.empty? - pp.breakable - pp.seplist(parents) do |parent| - role, parent = parent - pp.text "child #{role.to_a.first} of #{parent.to_s.gsub('Syskit::', '')}" - end + pp.breakable + pp.text "for #{failed_task.to_s.gsub('Syskit::', '')}" + pp.nest(2) do + unless parents.empty? + pp.breakable + pp.seplist(parents) do |parent| + role, parent = parent + pp.text "child #{role.to_a.first} of #{parent.to_s.gsub('Syskit::', '')}" end + end - pp.breakable - pp.seplist(candidates) do |cand| - srv, tasks = *cand - if tasks.empty? - pp.text "no candidates for #{srv.short_name}" - else - pp.text "candidates for #{srv.short_name}" - pp.nest(2) do - pp.breakable - pp.seplist(tasks) do |cand_t| - pp.text cand_t.to_s - end + pp.breakable + pp.seplist(candidates) do |cand| + srv, tasks = *cand + if tasks.empty? + pp.text "no candidates for #{srv.short_name}" + else + pp.text "candidates for #{srv.short_name}" + pp.nest(2) do + pp.breakable + pp.seplist(tasks) do |cand_t| + pp.text cand_t.to_s end end end @@ -467,122 +463,121 @@ def pretty_print(pp) class ConflictingDeploymentAllocation < SpecError include Syskit::NetworkGenerationsExceptionHelpers - attr_reader :deployment_to_tasks + attr_reader :orocos_name - def initialize(deployment_to_tasks, toplevel_tasks_to_requirements = {}) - @deployment_to_tasks = deployment_to_tasks + def initialize(orocos_name, tasks, toplevel_tasks_to_requirements = {}) + @orocos_name = orocos_name + @tasks = tasks @toplevel_tasks_to_requirements = toplevel_tasks_to_requirements - @deployment_to_execution_agent = \ - deployment_to_tasks.transform_values do |tasks| - tasks.first.execution_agent - end + @agent = tasks.first.execution_agent end def pretty_print(pp) - deployment_to_tasks.each do |orocos_name, tasks| - agent = @deployment_to_execution_agent[orocos_name] - deployment_m = agent.deployed_orogen_model_by_name(orocos_name) - pp.text( - "deployed task '#{orocos_name}' from deployment " \ - "'#{deployment_m.name}' defined in " \ - "'#{deployment_m.project.name}' on '#{agent.process_server_name}' " \ - "is assigned to #{tasks.size} tasks. Below is the list of " \ - "the dependent non-deployed actions. Right after the list " \ - "is a detailed explanation of why the first two tasks are not merged:" + deployment_m = @agent.deployed_orogen_model_by_name(orocos_name) + pp.text( + "deployed task '#{orocos_name}' from deployment " \ + "'#{deployment_m.name}' defined in " \ + "'#{deployment_m.project.name}' on '#{@agent.process_server_name}' " \ + "is assigned to #{@tasks.size} tasks. Below is the list of " \ + "the dependent non-deployed actions. Right after the list " \ + "is a detailed explanation of why the first two tasks are not merged:" + ) + @tasks.each do |t| + defs = find_all_related_syskit_actions( + t, @toplevel_tasks_to_requirements ) - tasks.each do |t| - defs = find_all_related_syskit_actions( - t, @toplevel_tasks_to_requirements - ) - print_dependent_definitions(pp, t, defs) - end - print_failed_merge_chain(pp, tasks[0], tasks[1]) + print_dependent_definitions(pp, t, defs) end + print_failed_merge_chain(pp, @tasks[0], @tasks[1]) end end - # Exception raised at the end of #resolve if some tasks do not have a + # Exception raised at the end of #resolve if a task does not have a # deployed equivalent - class MissingDeployments < SpecError - # The tasks that are not deployed, as a hash from the actual task to + class MissingDeployment < SpecError + # The tasks that is not deployed, as a hash from the actual task to # a set of [role_set, parent_task] pairs # # This is computed in #initialize as the dependency structure will # probably change afterwards - attr_reader :tasks + + # The task that is not deployed + attr_reader :task + + # Parent tasks of the not deployed task + attr_reader :parents + # All the available deployment candidates + attr_reader :candidates + # The deployment hints of the not deployed task + attr_reader :deployment_hints # Initializes this exception by providing a mapping from tasks that - # have no deployments to the deployment candidates + # have no deployments to the deployment candidates, and the specific task the + # error refers to. # # @param [Hash{TaskContext=>[Array>]}] tasks_with_candidates - def initialize(tasks_with_candidates) - @tasks = {} - tasks_with_candidates.each do |task_to_deploy, candidates| - parents = task_to_deploy.dependency_context - candidates = candidates.map do |deployed_task, existing_tasks| - existing_tasks = existing_tasks.map do |task| - [task, task.dependency_context] - end - [deployed_task, existing_tasks] + def initialize(task, candidates) + @task = task + @parents = task.dependency_context + @candidates = candidates.map do |deployed_task, existing_tasks| + existing_tasks = existing_tasks.map do |task| + [task, task.dependency_context] end - - @tasks[task_to_deploy] = [ - parents, candidates, task_to_deploy.deployment_hints - ] + [deployed_task, existing_tasks] end + @deployment_hints = task.deployment_hints end def pretty_print(pp) - pp.text "cannot deploy the following tasks" - tasks.each do |task, (parents, possible_deployments)| + pp.text "cannot deploy the following task" + pp.breakable + pp.text "#{task} (#{task.orogen_model.name})" + pp.nest(2) do pp.breakable - pp.text "#{task} (#{task.orogen_model.name})" - pp.nest(2) do - pp.breakable - pp.seplist(parents) do |parent_task| - role, parent_task = parent_task - pp.text "child #{role} of #{parent_task}" - end + pp.seplist(parents) do |parent_task| + role, parent_task = parent_task + pp.text "child #{role} of #{parent_task}" end end - tasks.each do |task, (_parents, possible_deployments, deployment_hints)| - has_free_deployment = possible_deployments.any? { |_, existing| existing.empty? } - pp.breakable - if has_free_deployment - pp.text "#{task}: multiple possible deployments, choose one with #prefer_deployed_tasks(deployed_task_name)" - unless deployment_hints.empty? - deployment_hints.each do |hint| - pp.text " current hints: #{deployment_hints.map(&:to_s).join(', ')}" - end + has_free_deployment = candidates.any? { |_, existing| existing.empty? } + pp.breakable + if has_free_deployment + pp.text "#{task}: multiple possible deployments, choose one with " \ + "#prefer_deployed_tasks(deployed_task_name)" + unless deployment_hints.empty? + deployment_hints.each do |hint| + pp.text " current hints: " \ + "#{deployment_hints.map(&:to_s).join(', ')}" end - elsif possible_deployments.empty? - pp.text "#{task}: no deployments available" - else - pp.text "#{task}: some deployments exist, but they are already used in this network" end + elsif candidates.empty? + pp.text "#{task}: no deployments available" + else + pp.text "#{task}: some deployments exist, but they are already used " \ + "in this network" + end - pp.nest(2) do - possible_deployments.each do |deployed_task, existing| - pp.breakable - process_server_name = deployed_task.configured_deployment - .process_server_name - orogen_model = deployed_task.configured_deployment - .orogen_model - pp.text( - "task #{deployed_task.mapped_task_name} from deployment " \ - "#{orogen_model.name} defined in " \ - "#{orogen_model.project.name} on #{process_server_name}" - ) - pp.nest(2) do - existing.each do |task, parents| - pp.breakable - msg = parents.map do |parent_task| - role, parent_task = *parent_task - "child #{role} of #{parent_task}" - end - pp.text "already used by #{task}: #{msg.join(', ')}" + pp.nest(2) do + candidates.each do |deployed_task, existing| + pp.breakable + process_server_name = deployed_task.configured_deployment + .process_server_name + orogen_model = deployed_task.configured_deployment + .orogen_model + pp.text( + "task #{deployed_task.mapped_task_name} from deployment " \ + "#{orogen_model.name} defined in " \ + "#{orogen_model.project.name} on #{process_server_name}" + ) + pp.nest(2) do + existing.each do |task, parents| + pp.breakable + msg = parents.map do |parent_task| + role, parent_task = *parent_task + "child #{role} of #{parent_task}" end + pp.text "already used by #{task}: #{msg.join(', ')}" end end end @@ -590,24 +585,25 @@ def pretty_print(pp) end end - # Exception raised at the end of #resolve if some tasks are referring to non-existing - # configurations + # Exception raised at the end of #resolve if a task is referring to non-existing + # configuration class MissingConfigurationSection < SpecError - # Association of tasks and the sections that are missing - attr_reader :missing_sections_by_task + # The sections that are missing + attr_reader :missing_sections + # The task with a missing configuration section + attr_reader :task - def initialize(missing_sections_by_task) - @missing_sections_by_task = missing_sections_by_task + def initialize(task, missing_sections) + @task = task + @missing_sections = missing_sections end def pretty_print(pp) pp.text "the following configuration sections are used but do not exist" - @missing_sections_by_task.each do |task, sections| - pp.breakable - pp.text "'#{sections.join('\', \'')}', in use by:" - pp.breakable - pp.text " #{task} (#{task.orogen_model.name})" - end + pp.breakable + pp.text "'#{missing_sections.join('\', \'')}', in use by:" + pp.breakable + pp.text " #{task} (#{task.orogen_model.name})" end end diff --git a/test/network_generation/test_system_network_deployer.rb b/test/network_generation/test_system_network_deployer.rb index 790ca7d2a..13f45e677 100644 --- a/test/network_generation/test_system_network_deployer.rb +++ b/test/network_generation/test_system_network_deployer.rb @@ -450,7 +450,7 @@ def make_candidates(count) task_m = Syskit::TaskContext.new_submodel deployment_m.orogen_model.task "task", task_m.orogen_model plan.add(task_m.new) - assert_raises(MissingDeployments) do + assert_raises(MissingDeployment) do deployer.validate_deployed_network end end @@ -463,19 +463,20 @@ def make_candidates(count) deployer.default_deployment_group .use_deployment(d0 => "test1_") - plan.add(task = task_m.new) - e = assert_raises(MissingDeployments) do + plan.add(task_m.new) + e = assert_raises(MissingDeployment) do deployer.validate_deployed_network end - info = e.tasks[task] - assert_equal [], info[0] # parents - candidates = info[1] + parents = e.parents + assert_equal [], parents + candidates = e.candidates + hints = e.deployment_hints assert_equal [d0, d0], candidates.map { |c, _| c.configured_deployment.model } assert_equal %w[test0_task0 test1_task0], candidates.map { |c, _| c.mapped_task_name } - assert_equal Set[], info[2] + assert_equal Set[], hints end it "snapshots the task's parents at the exception point" do @@ -487,13 +488,14 @@ def make_candidates(count) .use_deployment(d0 => "test1_") plan.add(parent = task_m.new) - parent.depends_on(task = task_m.new, role: "test") - e = assert_raises(MissingDeployments) do + parent.depends_on(task_m.new, role: "test") + e = assert_raises(MissingDeployment) do deployer.validate_deployed_network end - info = e.tasks[task] - assert_equal [["test", parent]], info[0] # parents + parents = e.parents + assert_equal [["test", parent]], parents + end end end @@ -512,8 +514,8 @@ def make_candidates(count) deployer.validate_deployed_network end assert_equal( - { task => ["something"] }, - e.missing_sections_by_task + ["something"], + e.missing_sections ) end diff --git a/test/network_generation/test_system_network_generator.rb b/test/network_generation/test_system_network_generator.rb index 7a4037108..a87619ebc 100644 --- a/test/network_generation/test_system_network_generator.rb +++ b/test/network_generation/test_system_network_generator.rb @@ -210,7 +210,7 @@ def arg=(value) local_net_gen.merge_solver .merge_task_contexts_with_same_agent = true - e = assert_raises(MissingDeployments) do + e = assert_raises(MissingDeployment) do local_net_gen.compute_system_network( [task_m.to_instance_requirements, task_m.to_instance_requirements @@ -218,8 +218,8 @@ def arg=(value) validate_deployed_network: true ) end - - assert_equal 1, e.tasks.size + end + end end end end diff --git a/test/runtime/test_apply_requirement_modifications.rb b/test/runtime/test_apply_requirement_modifications.rb index f4d642aa6..f9b8c80ab 100644 --- a/test/runtime/test_apply_requirement_modifications.rb +++ b/test/runtime/test_apply_requirement_modifications.rb @@ -121,9 +121,11 @@ module Runtime assert requirement_task.success? end - it "applies the computed network and emits the planning task's failed event if it raises" do + it "applies the computed network and emits the planning task's failed " \ + "event if it raises" do task_m = TaskContext.new_submodel - requirement_task = plan.add_permanent_task(task_m.to_instance_requirements.as_plan) + requirement_task = + plan.add_permanent_task(task_m.to_instance_requirements.as_plan) requirement_task = requirement_task.planning_task execute { requirement_task.start! } execute { Runtime.apply_requirement_modifications(plan) } @@ -131,8 +133,12 @@ module Runtime expect_execution { Runtime.apply_requirement_modifications(plan) } .to { have_error_matching Roby::PlanningFailedError } assert requirement_task.failed? - assert_kind_of Syskit::MissingDeployments, requirement_task.failed_event.last.context.first - assert_exception_can_be_pretty_printed(requirement_task.failed_event.last.context.first) + exception = requirement_task.failed_event.last.context.first + assert_kind_of Syskit::MissingDeployment, exception + assert_exception_can_be_pretty_printed( + requirement_task.failed_event.last.context.first + ) + end end def assert_resolution_cancelled # rubocop:disable Metrics/AbcSize diff --git a/test/test/test_network_manipulation.rb b/test/test/test_network_manipulation.rb index 17589ccf6..fc6450d70 100644 --- a/test/test/test_network_manipulation.rb +++ b/test/test/test_network_manipulation.rb @@ -233,13 +233,14 @@ module Test assert_equal "test_level", task.orocos_name end - it "on error, it filters out the planning failed and mission failed " \ + it "on error, it changes the planning failed and mission failed " \ "error caused by itself" do task_m = Syskit::TaskContext.new_submodel - assert_raises(MissingDeployments) do + assert_raises(Syskit::MissingDeployment) do syskit_deploy(task_m) end end + end end describe "#syskit_generate_network" do diff --git a/test/test/test_profile_assertions.rb b/test/test/test_profile_assertions.rb index b83b58615..ddbbe9ced 100644 --- a/test/test/test_profile_assertions.rb +++ b/test/test/test_profile_assertions.rb @@ -524,7 +524,7 @@ module Test end assert_match( - /cannot deploy the following tasks.*Task.*child test of Cmp/m, + /cannot deploy the following task.*Task.*child test of Cmp/m, PP.pp(e.each_original_exception.first, +"") ) end @@ -665,8 +665,8 @@ module Test end assert_match( - /cannot deploy the following tasks.*Task.*child test of Cmp/m, - PP.pp(e.each_original_exception.first, +"") + /cannot deploy the following task.*Task.*child test of Cmp/m, + PP.pp(e.original_exceptions.first, +"") ) end From e18116c11c64bd299ae02b5bd54279e34167e131 Mon Sep 17 00:00:00 2001 From: Jhonas Date: Wed, 29 Jan 2025 15:29:48 -0300 Subject: [PATCH 2/6] feat: create resolution error handler and wrap the error capture on feature flag This creates the structures that should be used to capture errors. One can retain the previous behavior by using the RaiseErrorHandler, or use the error capture via the ResolutionErrorHandler. The feature flag is meant to be used to switch between the two options. --- Rakefile | 15 +- lib/syskit/network_generation.rb | 9 +- .../resolution_error_handler.rb | 229 ++++++++++++++++++ lib/syskit/roby_app/configuration.rb | 10 + test/features/capture_errors.rb | 7 + 5 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 lib/syskit/network_generation/resolution_error_handler.rb create mode 100644 test/features/capture_errors.rb diff --git a/Rakefile b/Rakefile index 882dd5450..57e8c15d7 100644 --- a/Rakefile +++ b/Rakefile @@ -28,18 +28,21 @@ def minitest_set_options(test_task, name) test_task.options = "#{TESTOPTS} #{minitest_args} -- --simplecov-name=#{name}" end -def core(early_deploy: false) +def core(early_deploy: false, capture_errors_during_network_resolution: false) s = ":no-early-deploy" - if early_deploy + if capture_errors_during_network_resolution + s = ":capture-errors" + files_setup = ["test/features/capture_errors.rb"] + elsif early_deploy s = ":early-deploy" - early_deploy_setup = ["test/features/early_deploy.rb"] + files_setup = ["test/features/early_deploy.rb"] end Rake::TestTask.new("test:core#{s}") do |t| t.libs << "." t.libs << "lib" minitest_set_options(t, "core") - test_files = FileList["test/**/test_*.rb", *early_deploy_setup] + test_files = FileList["test/**/test_*.rb", *files_setup] test_files = test_files .exclude("test/ros/**/*.rb") .exclude("test/gui/**/*.rb") @@ -78,9 +81,11 @@ Rake::TestTask.new("test:gui") do |t| end core early_deploy: true +core capture_errors_during_network_resolution: true core desc "Run core library tests, excluding GUI and live tests" -task "test:core" => ["test:core:no-early-deploy", "test:core:early-deploy"] +task "test:core" => ["test:core:no-early-deploy", "test:core:early-deploy", + "test:core:capture-errors"] desc "Run all tests" task "test" => ["test:gui", "test:core", "test:live", "test:telemetry"] diff --git a/lib/syskit/network_generation.rb b/lib/syskit/network_generation.rb index 10972d85e..7d34d99cc 100644 --- a/lib/syskit/network_generation.rb +++ b/lib/syskit/network_generation.rb @@ -8,11 +8,12 @@ module NetworkGeneration end end +require "syskit/network_generation/async" require "syskit/network_generation/dataflow_computation" require "syskit/network_generation/dataflow_dynamics" -require "syskit/network_generation/merge_solver" -require "syskit/network_generation/system_network_generator" -require "syskit/network_generation/system_network_deployer" require "syskit/network_generation/engine" -require "syskit/network_generation/async" require "syskit/network_generation/logger" +require "syskit/network_generation/merge_solver" +require "syskit/network_generation/resolution_error_handler" +require "syskit/network_generation/system_network_deployer" +require "syskit/network_generation/system_network_generator" diff --git a/lib/syskit/network_generation/resolution_error_handler.rb b/lib/syskit/network_generation/resolution_error_handler.rb new file mode 100644 index 000000000..1145aa55c --- /dev/null +++ b/lib/syskit/network_generation/resolution_error_handler.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +module Syskit + module NetworkGeneration + # This collects resolution errors so they can be raised. Useful for testing + # contexts, like Syskit::Test::NetworkManipulation. + class PartialNetworkResolution < Roby::ExceptionBase + def initialize(errors) + original_errors = errors.flat_map(&:original_exception) + super(original_errors) + end + end + + # This is used to capture failures during the network generation process. Each + # failure bundles the toplevel tasks that are related to the task that originates + # the failure. It should be transformed into a ResolutionError for each related + # task and propagated. + InternalResolutionFailure = + Struct.new(:failed_task, :merge_solver, :plan, + :original_exception, :message) do + # Constructor for the InternalResolutionFailure. + # + # Takes an array of toplevel tasks with their index in the toplevel tasks + # array. Optionally, one can provide the original exception and the error + # message as well. + def initialize( + failed_task, original_exception, message, merge_solver, plan + ) + self.failed_task = failed_task + self.merge_solver = merge_solver + self.plan = plan + self.original_exception = original_exception + self.message = message + end + + # Convert to ResolutionError by replacing the requirement of a failed task + # with its toplevel version. + # + # @return [ResolutionError] + def to_resolution_errors(instance) + ResolutionError.new(instance, original_exception, message) + end + end + + # Wraps errors that happened during the network generation and deployment. + # + # The tasks that relate with wrapped errors are removed from the transaction, + # alongside with anything that depend on them. + # + # This is not meant to be raised, instead it should be propagated using the + # execution engine error handling. + class ResolutionError + attr_reader :planned_task, :planning_task, :original_exception + + def initialize(failed_task, original_exception, message = nil) + validate(failed_task) + + original_exception = original_exception.exception(message) if message + @original_exception = original_exception + @planned_task = failed_task.planned_task + @planning_task = failed_task + end + + def validate(failed_task) + raise ArgumentError, "provided no failed task" unless failed_task + + return if failed_task.planned_task + + raise ArgumentError, + "provided task #{failed_task} has no planned_task" + end + end + + # Captures resolution failures during the network generation process and process + # them into resolution errors. The resolution errors have the necessary + # information to propagate the errors in other contexts. + class ResolutionErrorHandler + # The currently registered failures + attr_reader :resolution_failures + + def initialize(plan, merge_solver) + @resolution_failures = [] + @plan = plan + @merge_solver = merge_solver + end + + def register_resolution_failures(failures, _exception, _message) + failures.each do |failure| + @resolution_failures << failure + end + end + + def register_resolution_failures_from_exception( + tasks, exception, message = nil + ) + tasks.each do |task| + failures = failures_from_exception( + [task], @plan, @merge_solver, exception, message + ) + register_resolution_failures(failures, exception, message) + end + end + + # Find the index of the toplevel tasks that depend on the given task + # + # These tasks are in the work plan. The engine will map them to + # the corresponding tasks in the real plan + # + # @param [Syskit::Component] task the task with are depended by the toplevel + # tasks + # @param [Array] toplevel_tasks the list of all the + # toplevel tasks + # @param [Roby::Plan] plan the plan on which we are working + # @param [NetworkGeneration::MergeSolver] merge_solver the merge solver object + # with records with the task replacements so far + # @return [Array] the list of toplevel tasks + # depending on the given task and their indexes in the provided list of all + # toplevel tasks + def find_index_of_toplevel_tasks_depending_on( + task, toplevel_tasks, plan, merge_solver + ) + return [] unless toplevel_tasks + + # Build the mapping of the toplevel task into the actual task + # in the plan that represents it + toplevel_tasks_indexes = + toplevel_tasks + .each_with_index + .group_by { |t, _i| merge_solver.replacement_for(t) } + .transform_values { |v| v.flatten[1] } + + inv_dependency_graph = + plan + .task_relation_graph_for(Roby::TaskStructure::Dependency) + .reverse + + indexes = [] + inv_dependency_graph.depth_first_visit(task) do |parent| + if (index = toplevel_tasks_indexes[parent]) + indexes << index + end + end + indexes + end + + def failures_from_exception( + tasks, plan, merge_solver, exception, message = "" + ) + tasks.map do |task| + NetworkGeneration::InternalResolutionFailure.new( + task, exception, message, merge_solver.dup, plan.dup + ) + end + end + + def process_failures(required_instances, cleanup_failed_tasks:) + requirement_tasks = required_instances.keys + toplevel_tasks = required_instances.values + + resolution_errors = @resolution_failures.flat_map do |failure| + failed_task = failure.failed_task + indexes = find_index_of_toplevel_tasks_depending_on( + failed_task, toplevel_tasks, failure.plan, failure.merge_solver + ) + indexes.map do |i| + instance = requirement_tasks[i] + failure.to_resolution_errors(instance) + end + end + + if cleanup_failed_tasks + cleanup_resolution_errors( + resolution_errors, required_instances, @plan + ) + end + + resolution_errors + end + + # Cleanup the requirement tasks and toplevel tasks that encountered resolution + # failures. + # + # The resolution errors are used to resolve which tasks should be cleaned up. + # Both requirement tasks and toplevel tasks remain one to one mappings of each + # other after this operation. + def cleanup_resolution_errors( + resolution_errors, required_instances, work_plan + ) + cleaned_up = {} + resolution_errors.each do |error| + requirement_task = error.planning_task + toplevel_task = required_instances[requirement_task] + if cleaned_up[requirement_task] + # Even if a task have multiple resolution errors, enforce that it + # is only cleaned up once. + next + elsif !toplevel_task + raise "trying to cleanup a requirement task that doesnt have" \ + "a top level task assigned to it. Maybe it was already " \ + "cleaned up?!" + end + + required_instances.delete requirement_task + work_plan.remove_task(toplevel_task) + cleaned_up[requirement_task] = toplevel_task + end + end + end + + # A resolution error handler that raises instead of capturing the error + class RaiseErrorHandler + def register_resolution_failures_from_exception( + _tasks, exception, message = nil + ) + raise exception, message || "" + end + + def register_resolution_failures(_failures, exception, message) + raise exception, message || "" + end + + # Noop to satisfy the resolution error handler interface. Should return no + # errors, since it raises if any errors happened + def process_failures(*) + [] + end + end + end +end diff --git a/lib/syskit/roby_app/configuration.rb b/lib/syskit/roby_app/configuration.rb index c4769c4f4..936b9b62a 100644 --- a/lib/syskit/roby_app/configuration.rb +++ b/lib/syskit/roby_app/configuration.rb @@ -134,11 +134,20 @@ def early_deploy? @early_deploy end + # Whether to capture errors during network resolution instead of raising them. + def capture_errors_during_network_resolution? + @capture_errors_during_network_resolution + end + # Controls where the deployment stage happens # # @see early_deploy? attr_writer :early_deploy + # Controls whether to capture errors instead of raising them during network + # resolution + attr_writer :capture_errors_during_network_resolution + # Controls whether the orogen types should be exported as Ruby # constants # @@ -173,6 +182,7 @@ def initialize(app) @register_self_on_name_server = (ENV["SYSKIT_REGISTER_SELF_ON_NAME_SERVER"] != "0") @strict_model_for = false @early_deploy = false + @capture_errors_during_network_resolution = false @log_rotation_period = nil @log_transfer = LogTransferManager::Configuration.new( diff --git a/test/features/capture_errors.rb b/test/features/capture_errors.rb new file mode 100644 index 000000000..0a03c1bed --- /dev/null +++ b/test/features/capture_errors.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Even though the early deploy feature isnt a direct requirement to the error capture, +# it adds more points where exception might be raised. So it makes sense to leave it +# enabled +Syskit.conf.early_deploy = true +Syskit.conf.capture_errors_during_network_resolution = true From 69c72cccdf6d6d349b1dc6f3df37636296d7edeb Mon Sep 17 00:00:00 2001 From: Jhonas Date: Wed, 29 Jan 2025 15:29:48 -0300 Subject: [PATCH 3/6] feat: capture errors during the network generation instead of raising them Through the resolution error handler, capture the errors during the network generation. Then, they are propagated as failed events for each individual task that caused the error. The previous behavior is enabled when the capture_errors_during_network_resolution feature flag is off. That way, any exception is immediately raised. Some behaviors were slightly changed to work for both the old and new features, specially for unit tests. --- lib/syskit/network_generation/async.rb | 12 +- lib/syskit/network_generation/engine.rb | 101 +++++-- .../system_network_deployer.rb | 69 +++-- .../system_network_generator.rb | 251 +++++++++++------ .../apply_requirement_modifications.rb | 8 +- test/network_generation/test_engine.rb | 92 ++++++- .../test_system_network_deployer.rb | 37 +++ .../test_system_network_generator.rb | 252 ++++++++++++++++++ .../test_apply_requirement_modifications.rb | 64 +++++ 9 files changed, 756 insertions(+), 130 deletions(-) diff --git a/lib/syskit/network_generation/async.rb b/lib/syskit/network_generation/async.rb index 106f5fbf1..67404a980 100644 --- a/lib/syskit/network_generation/async.rb +++ b/lib/syskit/network_generation/async.rb @@ -119,6 +119,9 @@ def cancelled? class InvalidState < RuntimeError; end + SystemNetworkPlanApplyResult = + Struct.new :fulfilled, :instances, :errors, keyword_init: true + # Apply the result of the generation # # @return [Boolean] true if the result has been applied, and false @@ -133,14 +136,17 @@ def apply engine = future.engine if @cancelled engine.discard_work_plan - false + SystemNetworkPlanApplyResult.new(fulfilled: false) elsif future.fulfilled? - required_instances = future.value + required_instances, resolution_errors = future.value begin engine.apply_system_network_to_plan( required_instances, **@apply_system_network_options ) - true + SystemNetworkPlanApplyResult.new( + fulfilled: true, instances: required_instances, + errors: resolution_errors + ) rescue ::Exception => e engine.handle_resolution_exception(e, on_error: Engine.on_error) raise e diff --git a/lib/syskit/network_generation/engine.rb b/lib/syskit/network_generation/engine.rb index d6edc0952..19467f7fd 100644 --- a/lib/syskit/network_generation/engine.rb +++ b/lib/syskit/network_generation/engine.rb @@ -93,10 +93,13 @@ def available_deployments # # This does not access {#real_plan} def compute_deployed_network( + error_handler: RaiseErrorHandler.new, + required_instances: [], default_deployment_group: Syskit.conf.deployment_group, compute_policies: true, validate_deployed_network: true ) + resolution_errors = [] log_timepoint_group "deploy_system_network" do deployer = SystemNetworkDeployer.new( work_plan, @@ -105,7 +108,12 @@ def compute_deployed_network( default_deployment_group: default_deployment_group ) - deployer.deploy(validate: validate_deployed_network) + deployer.deploy( + error_handler: error_handler, validate: validate_deployed_network + ) + resolution_errors = error_handler.process_failures( + required_instances, cleanup_failed_tasks: true + ) end # Now that we have a deployed network, we can compute the @@ -122,7 +130,7 @@ def compute_deployed_network( @deployment_tasks = work_plan.find_local_tasks(Deployment).to_set @deployed_tasks = work_plan.find_local_tasks(Component).to_set - nil + resolution_errors end # Apply the deployed network created with @@ -701,32 +709,44 @@ def self.discover_requirement_tasks_from_plan(plan) def compute_system_network( requirement_tasks = Engine.discover_requirement_tasks_from_plan(real_plan), + error_handler: RaiseErrorHandler.new, garbage_collect: true, validate_abstract_network: true, validate_generated_network: true, default_deployment_group: Syskit.conf.deployment_group, - validate_deployed_network: (true if Syskit.conf.early_deploy?), - early_deploy: Syskit.conf.early_deploy? + validate_deployed_network: Syskit.conf.early_deploy?, + early_deploy: false, + cleanup_resolution_errors: true ) requirement_tasks = requirement_tasks.to_a instance_requirements = requirement_tasks.map(&:requirements) merge_solver.merge_task_contexts_with_same_agent = early_deploy + system_network_generator = SystemNetworkGenerator.new( work_plan, + error_handler: error_handler, event_logger: event_logger, merge_solver: merge_solver, default_deployment_group: default_deployment_group, early_deploy: early_deploy ) - toplevel_tasks = system_network_generator.generate( - instance_requirements, + toplevel_tasks = + system_network_generator.instanciate_system_network( + instance_requirements + ) + + system_network_generator.resolve_system_network( garbage_collect: garbage_collect, validate_abstract_network: validate_abstract_network, validate_generated_network: validate_generated_network, validate_deployed_network: validate_deployed_network ) + required_instances = Hash[requirement_tasks.zip(toplevel_tasks)] - Hash[requirement_tasks.zip(toplevel_tasks)] + resolution_errors = error_handler.process_failures( + required_instances, cleanup_failed_tasks: cleanup_resolution_errors + ) + [required_instances, resolution_errors] end # Computes the system network, that is the network that fullfills @@ -759,30 +779,44 @@ def resolve_system_network( compute_deployments: true, default_deployment_group: Syskit.conf.deployment_group, compute_policies: true, - early_deploy: Syskit.conf.early_deploy? + early_deploy: Syskit.conf.early_deploy?, + capture_errors_during_network_resolution: + Syskit.conf.capture_errors_during_network_resolution?, + cleanup_resolution_errors: true ) - merge_solver.merge_task_contexts_with_same_agent = early_deploy - required_instances = compute_system_network( + + error_handler = if capture_errors_during_network_resolution + ResolutionErrorHandler.new(work_plan, merge_solver) + else + RaiseErrorHandler.new + end + required_instances, resolution_errors = compute_system_network( requirement_tasks, + error_handler: error_handler, garbage_collect: garbage_collect, validate_abstract_network: validate_abstract_network, validate_generated_network: validate_generated_network, default_deployment_group: (default_deployment_group if early_deploy), validate_deployed_network: validate_deployed_network, - early_deploy: early_deploy && compute_deployments + early_deploy: early_deploy && compute_deployments, + cleanup_resolution_errors: cleanup_resolution_errors ) if compute_deployments log_timepoint_group "compute_deployed_network" do - compute_deployed_network( - default_deployment_group: default_deployment_group, - compute_policies: compute_policies, - validate_deployed_network: validate_deployed_network - ) + deployment_resolution_errors = + compute_deployed_network( + error_handler: error_handler, + required_instances: required_instances, + default_deployment_group: default_deployment_group, + compute_policies: compute_policies, + validate_deployed_network: validate_deployed_network + ) + resolution_errors.concat(deployment_resolution_errors) end end - required_instances + [required_instances, resolution_errors] end # Generate the deployment according to the current requirements, and @@ -819,10 +853,13 @@ def resolve( validate_generated_network: true, validate_deployed_network: true, validate_final_network: true, - early_deploy: Syskit.conf.early_deploy? + early_deploy: Syskit.conf.early_deploy?, + capture_errors_during_network_resolution: + Syskit.conf.capture_errors_during_network_resolution?, + cleanup_resolution_errors: on_error != :commit ) merge_solver.merge_task_contexts_with_same_agent = early_deploy - required_instances = resolve_system_network( + required_instances, resolution_errors = resolve_system_network( requirement_tasks, garbage_collect: garbage_collect, validate_abstract_network: validate_abstract_network, @@ -831,15 +868,24 @@ def resolve( default_deployment_group: default_deployment_group, compute_policies: compute_policies, validate_deployed_network: validate_deployed_network, - early_deploy: early_deploy + early_deploy: early_deploy, + capture_errors_during_network_resolution: + capture_errors_during_network_resolution, + cleanup_resolution_errors: cleanup_resolution_errors ) + unless resolution_errors.empty? || cleanup_resolution_errors + handle_resolution_errors(resolution_errors, on_error: on_error) + return resolution_errors + end + apply_system_network_to_plan( required_instances, compute_deployments: compute_deployments, garbage_collect: garbage_collect, validate_final_network: validate_final_network ) + resolution_errors rescue Exception => e # rubocop:disable Lint/RescueException handle_resolution_exception(e, on_error: on_error) raise @@ -916,6 +962,21 @@ def commit_work_plan end end + def handle_resolution_errors(errors, on_error: :discard) + return if work_plan.finalized? || work_plan == real_plan + + if on_error == :save + errors.each do |e| + handle_resolution_exception(e, on_error: on_error) + end + elsif on_error == :commit + work_plan.commit_transaction + else + errors.each { |err| real_plan.execution_engine.add_error(err) } + work_plan.discard_transaction + end + end + def handle_resolution_exception(e, on_error: :discard) return if work_plan.finalized? || work_plan == real_plan diff --git a/lib/syskit/network_generation/system_network_deployer.rb b/lib/syskit/network_generation/system_network_deployer.rb index 1794cc6c7..cf9e97df9 100644 --- a/lib/syskit/network_generation/system_network_deployer.rb +++ b/lib/syskit/network_generation/system_network_deployer.rb @@ -39,7 +39,6 @@ def initialize(plan, event_logger: plan.event_logger, merge_solver: MergeSolver.new(plan), default_deployment_group: Syskit.conf.deployment_group) - @plan = plan @event_logger = event_logger @merge_solver = merge_solver @@ -56,7 +55,8 @@ def initialize(plan, # will run on the generated network # @return [Set] the set of tasks for which the deployer could # not find a deployment - def deploy(validate: true, reuse_deployments: false, deployment_tasks: {}) + def deploy(error_handler: RaiseErrorHandler.new, validate: true, + reuse_deployments: false, deployment_tasks: {}) debug "Deploying the system network" all_tasks = plan.find_local_tasks(TaskContext).to_a @@ -68,7 +68,7 @@ def deploy(validate: true, reuse_deployments: false, deployment_tasks: {}) log_timepoint "apply_selected_deployments" if validate - validate_deployed_network + validate_deployed_network(error_handler: error_handler) log_timepoint "validate_deployed_network" end @@ -204,32 +204,36 @@ def apply_selected_deployments(selected_deployments, deployment_tasks = {}) # Sanity checks to verify that the result of #deploy_system_network # is valid # - # @raise [MissingDeployments] if some tasks could not be deployed - # @raise [MissingConfigurationSection] if some configuration sections are - # used but do not exist - def validate_deployed_network - verify_all_tasks_deployed - verify_all_configurations_exist - end - - def verify_all_tasks_deployed - self.class.verify_all_tasks_deployed(plan, default_deployment_group) + # @return [Array] all the resolution errors of the deployed + # network. + def validate_deployed_network(error_handler: RaiseErrorHandler.new) + verify_all_tasks_deployed(error_handler: error_handler) + verify_all_configurations_exist(error_handler: error_handler) end # Verifies that all tasks in the plan are deployed # - # @param [Component=>DeploymentGroup] deployment_groups which - # deployment groups has been used for which task. This is used - # to generate the error messages when needed. - def self.verify_all_tasks_deployed(plan, default_deployment_group) + # @return [Array] resolution errors of all the tasks that + # are missing a deployment + def verify_all_tasks_deployed(resolution_error_handler) + self.class.verify_all_tasks_deployed( + plan, default_deployment_group, resolution_error_handler + ) + end + + def self.verify_all_tasks_deployed( + plan, default_deployment_group, error_handler: RaiseErrorHandler.new + ) not_deployed = plan.find_local_tasks(TaskContext) .not_finished.not_abstract .find_all { |t| !t.execution_agent } - return if not_deployed.empty? + return [] if not_deployed.empty? tasks_with_candidates = {} - not_deployed.each do |task| + # This is reversed to give preference to child tasks, as they contain + # more information about the dependency context then their parents + not_deployed.reverse_each do |task| candidates = find_all_suitable_deployments_for( default_deployment_group, task @@ -244,15 +248,21 @@ def self.verify_all_tasks_deployed(plan, default_deployment_group) tasks_with_candidates[task] = candidates end - raise MissingDeployments.new(tasks_with_candidates), - "there are tasks for which it exists no deployed equivalent: " \ - "#{not_deployed.map { |m| "#{m}(#{m.orogen_model.name})" }}" + tasks_with_candidates.each do |task, candidates| + message = + "#{task}(#{task.orogen_model.name}) has no deployed equivalent" + exception = MissingDeployment.new(task, candidates) + error_handler.register_resolution_failures_from_exception( + [task], exception, message + ) + end.flatten end # Verifies that all selected configuration sections exist # - # @raise [MissingConfigurationSection] - def verify_all_configurations_exist + # @return [Array] resolution errors of all the tasks that + # are missing a configuration section + def verify_all_configurations_exist(error_handler: RaiseErrorHandler.new) tasks = plan.find_local_tasks(TaskContext) .not_finished.not_abstract @@ -271,10 +281,15 @@ def verify_all_configurations_exist h[t] = sections unless sections.empty? end - return if missing.empty? + return [] if missing.empty? - raise MissingConfigurationSection.new(missing), - "some configuration sections are used but not defined" + message = "some configuration sections are used but not defined" + missing.map do |task, sections| + exception = MissingConfigurationSection.new(task, sections) + error_handler.register_resolution_failures_from_exception( + [task], exception, message + ) + end.flatten end # Try to resolve a set of deployment candidates for a given task diff --git a/lib/syskit/network_generation/system_network_generator.rb b/lib/syskit/network_generation/system_network_generator.rb index 9d701c295..39552a50a 100644 --- a/lib/syskit/network_generation/system_network_generator.rb +++ b/lib/syskit/network_generation/system_network_generator.rb @@ -16,18 +16,24 @@ class SystemNetworkGenerator :merge_solver, :default_deployment_group + # The error handler to register and process resolution errors + attr_reader :error_handler + # Indicates if deployment stage happens within network generation def early_deploy? @early_deploy end - def initialize(plan, + def initialize(plan, # rubocop:disable Metrics/ParameterLists event_logger: plan.event_logger, merge_solver: MergeSolver.new(plan), default_deployment_group: nil, - early_deploy: false) + early_deploy: false, + error_handler: RaiseErrorHandler.new) if merge_solver.plan != plan - raise ArgumentError, "gave #{merge_solver} as merge solver, which applies on #{merge_solver.plan}. Was expecting #{plan}" + raise ArgumentError, + "gave #{merge_solver} as merge solver, which applies on " \ + "#{merge_solver.plan}. Was expecting #{plan}" end @plan = plan @@ -35,10 +41,12 @@ def initialize(plan, @merge_solver = merge_solver @default_deployment_group = default_deployment_group @early_deploy = early_deploy + @error_handler = error_handler end # Generate the network in the plan - # param [bool] validate_deployed_network controls whether or not the + # + # @param [bool] validate_deployed_network controls whether or not the # deployed network is validated, when #early_deploy? is true # # @return [HashArray>] the @@ -53,11 +61,13 @@ def generate(instance_requirements, # We first generate a non-deployed network that fits all # requirements. log_timepoint_group "compute_system_network" do - compute_system_network(instance_requirements, - garbage_collect: garbage_collect, - validate_abstract_network: validate_abstract_network, - validate_generated_network: validate_generated_network, - validate_deployed_network: validate_deployed_network) + compute_system_network( + instance_requirements, + garbage_collect: garbage_collect, + validate_abstract_network: validate_abstract_network, + validate_generated_network: validate_generated_network, + validate_deployed_network: validate_deployed_network + ) end end @@ -142,10 +152,6 @@ def instanciate(instance_requirements) end end log_timepoint "device_allocation" - Engine.instanciation_postprocessing.each do |block| - block.call(self, plan) - log_timepoint "postprocessing:#{block}" - end toplevel_tasks end @@ -216,37 +222,40 @@ def deploy(deployment_tasks) deployment_tasks: deployment_tasks) end + def instanciate_system_network(instance_requirements) + @toplevel_tasks = log_timepoint_group "instanciate" do + instanciate(instance_requirements) + end + Engine.instanciation_postprocessing.each do |block| + block.call(self, plan) + log_timepoint "postprocessing:#{block}" + end + @toplevel_instance_requirements = instance_requirements + @toplevel_tasks + end + # Compute in #plan the network needed to fullfill the requirements # # This network is neither validated nor tied to actual deployments - def compute_system_network(instance_requirements, garbage_collect: true, + def resolve_system_network(error_handler: @error_handler, + garbage_collect: true, validate_abstract_network: true, validate_generated_network: true, validate_deployed_network: true) - - @toplevel_tasks = log_timepoint_group "instanciate" do - instanciate(instance_requirements) - end - - @toplevel_instance_requirements = instance_requirements - deployment_tasks = {} - deploy(deployment_tasks) if early_deploy? - merge_solver.merge_identical_tasks log_timepoint "merge" Engine.instanciated_network_postprocessing.each do |block| block.call(self, plan) log_timepoint "postprocessing:#{block}" end + link_to_busses log_timepoint "link_to_busses" deploy(deployment_tasks) if early_deploy? - merge_solver.merge_identical_tasks - log_timepoint "merge" self.class.remove_abstract_composition_optional_children(plan) @@ -280,21 +289,52 @@ def compute_system_network(instance_requirements, garbage_collect: true, end log_timepoint "postprocessing" + validate_network( + error_handler: error_handler, + validate_abstract_network: validate_abstract_network, + validate_generated_network: validate_generated_network, + validate_deployed_network: + early_deploy? && validate_deployed_network + ) + @toplevel_tasks + end + + # Compute in #plan the network needed to fullfill the requirements + # + # This network is neither validated nor tied to actual deployments + def compute_system_network(instance_requirements, + garbage_collect: true, + validate_abstract_network: true, + validate_generated_network: true, + validate_deployed_network: true) + error_handler = RaiseErrorHandler.new + instanciate_system_network(instance_requirements) + resolve_system_network( + error_handler: error_handler, + garbage_collect: garbage_collect, + validate_abstract_network: validate_abstract_network, + validate_generated_network: validate_generated_network, + validate_deployed_network: validate_deployed_network + ) + end + + def validate_network(error_handler: @error_handler, + validate_abstract_network: true, + validate_generated_network: true, + validate_deployed_network: true) if validate_abstract_network self.validate_abstract_network log_timepoint "validate_abstract_network" end if validate_generated_network - self.validate_generated_network + self.validate_generated_network(error_handler: error_handler) log_timepoint "validate_generated_network" end + return unless early_deploy? && validate_deployed_network - if early_deploy? && validate_deployed_network - self.validate_deployed_network - end - - @toplevel_tasks + self.validate_deployed_network(error_handler: error_handler) + log_timepoint "validate_deployed_network" end def toplevel_tasks_to_requirements @@ -304,20 +344,28 @@ def toplevel_tasks_to_requirements .each_with_object({}) { |(t, ir), h| (h[t] ||= []) << ir } end - # Verifies that the task allocation is complete + # Verifies that the task allocation is complete. Return any + # resolution failure. # # @param [Roby::Plan] plan the plan on which we are working - # @raise [TaskAllocationFailed] if some abstract tasks are still in - # the plan + # @param [NetworkGeneration::ResolutionErrorHandler] error_handler the error + # handler object to capture or raise any exceptions + # @param [Array] components the list of all the abstract + # components + # @return [Array] the resolution failures def self.verify_task_allocation( - plan, components: plan.find_local_tasks(AbstractComponent) + plan, error_handler: RaiseErrorHandler.new, + components: plan.find_local_tasks(AbstractComponent) ) still_abstract = components.find_all(&:abstract?) - return if still_abstract.empty? - - raise TaskAllocationFailed.new(self, still_abstract), - "could not find implementation for the following abstract " \ - "tasks: #{still_abstract}" + still_abstract.each do |task| + exception = TaskAllocationFailed.new(self, [task]) + error_handler.register_resolution_failures_from_exception( + [task], exception, + "could not find implementation for the following abstract " \ + "task: #{task}" + ) + end end # Verifies that there are no multiple output - single input @@ -348,37 +396,31 @@ def self.verify_no_multiplexing_connections(plan) end end - # Verifies that all tasks that are device drivers have at least one - # device attached, and that the same device is not attached to more - # than one task in the plan + # Verifies that the same device is not attached to more than one task in the + # plan. # - # @param [Roby::Plan] plan the plan on which we are working - # @raise [DeviceAllocationFailed] if some device drivers are not - # attached to any device - # @raise [SpecError] if some devices are assigned to more than one - # task - def self.verify_device_allocation(plan, toplevel_tasks_to_requirements = {}) - components = plan.find_local_tasks(Syskit::Device).to_a - - # Check that all devices are properly assigned - missing_devices = components.find_all do |t| - t.model.each_master_driver_service - .any? { |srv| !t.find_device_attached_to(srv) } - end - unless missing_devices.empty? - raise DeviceAllocationFailed.new(plan, missing_devices), - "could not allocate devices for the following tasks: " \ - "#{missing_devices}" - end - + # @param [Array] components the list of all the abstract + # components + # @param [Hash] toplevel_tasks the list of all the + # toplevel tasks + # @param [NetworkGeneration::MergeSolver] merge_solver the merge solver object + # with records with the task replacements so far + # @param [Hash 1 end - return if using_same_deployment.empty? + return [] if using_same_deployment.empty? - raise ConflictingDeploymentAllocation.new( - using_same_deployment, toplevel_tasks_to_requirements - ), "there are deployments used multiple times" + using_same_deployment.each do |orocos_name, tasks| + exception = ConflictingDeploymentAllocation.new( + orocos_name, tasks, toplevel_tasks_to_requirements + ) + error_handler.register_resolution_failures_from_exception( + tasks, exception, "deployment used multiple times" + ) + end end # Validates the network generated by {#compute_system_network} @@ -414,24 +503,32 @@ def validate_abstract_network end # Validates the network generated by {#compute_system_network} - def validate_generated_network - self.class.verify_task_allocation(plan) - self.class.verify_device_allocation(plan, toplevel_tasks_to_requirements) + def validate_generated_network(error_handler: @error_handler) + self.class.verify_task_allocation(plan, error_handler: error_handler) + + self.class.verify_device_allocation( + plan, toplevel_tasks_to_requirements, error_handler: error_handler + ) super if defined? super end - def validate_deployed_network - self.class.verify_all_tasks_deployed(plan, default_deployment_group) + def validate_deployed_network(error_handler: @error_handler) + self.class.verify_all_tasks_deployed( + plan, default_deployment_group, error_handler: error_handler + ) self.class.verify_all_deployments_are_unique( - plan, toplevel_tasks_to_requirements.dup + plan, toplevel_tasks_to_requirements.dup, error_handler: error_handler ) super if defined? super end - def self.verify_all_tasks_deployed(plan, default_deployment_group) + def self.verify_all_tasks_deployed( + plan, default_deployment_group, error_handler: RaiseErrorHandler.new + ) SystemNetworkDeployer.verify_all_tasks_deployed( plan, - default_deployment_group + default_deployment_group, + error_handler: error_handler ) end end diff --git a/lib/syskit/runtime/apply_requirement_modifications.rb b/lib/syskit/runtime/apply_requirement_modifications.rb index 2cf2c9adb..102d9eeea 100644 --- a/lib/syskit/runtime/apply_requirement_modifications.rb +++ b/lib/syskit/runtime/apply_requirement_modifications.rb @@ -102,15 +102,19 @@ def syskit_apply_async_resolution_results find_tasks(Syskit::InstanceRequirementsTask).running begin - return unless syskit_current_resolution.apply + resolution_apply_result = syskit_current_resolution.apply + return unless resolution_apply_result.fulfilled ensure syskit_current_resolution_keepalive.discard_transaction @syskit_current_resolution = nil end - running_requirement_tasks.each do |t| + resolution_apply_result.instances.each_key do |t| t.success_event.emit end + resolution_apply_result.errors.group_by(&:planning_task).each do |task, e| + task.failed_event.emit e.flat_map(&:original_exception) + end nil rescue ::Exception => e # rubocop:disable Lint/RescueException running_requirement_tasks.each do |t| diff --git a/test/network_generation/test_engine.rb b/test/network_generation/test_engine.rb index 7e0ce1c08..f9075c571 100644 --- a/test/network_generation/test_engine.rb +++ b/test/network_generation/test_engine.rb @@ -105,7 +105,7 @@ def work_plan flexmock(requirements).should_receive(:instanciate) .and_return(instanciated_task = simple_component_model.new) syskit_stub_configured_deployment(simple_component_model) - mapping = syskit_engine.compute_system_network( + mapping, = syskit_engine.compute_system_network( [planning_task], default_deployment_group: default_deployment_group ) @@ -899,6 +899,96 @@ def deploy_dev_and_bus end end end + + describe "capture errors during network resolution" do + before do + @srv_m = Syskit::DataService.new_submodel + @task_m = Syskit::TaskContext.new_submodel + @task_m.argument :arg + @cmp_m = Syskit::Composition.new_submodel + @cmp_m.add @task_m, as: "test" + @cmp_m.add @srv_m, as: "other" + @deployment_m = syskit_stub_configured_deployment(@task_m, "task1") + end + + describe "#resolve_system_network" do + it "capture the errors from the network generator instead of " \ + "raising them" do + plan.add(t1 = @cmp_m.as_plan) + _, errors = syskit_engine.resolve_system_network( + [t1.planning_task], + capture_errors_during_network_resolution: true, + default_deployment_group: default_deployment_group, + early_deploy: true + ) + assert_equal 1, errors.size + assert_kind_of TaskAllocationFailed, + errors.first.original_exception + end + + it "capture the errors from the network deployer instead of " \ + "raising them" do + task2_m = Syskit::TaskContext.new_submodel + task2_m.provides @srv_m, as: "srv" + t1 = @cmp_m.use("test" => @task_m.new(arg: 1), + "other" => task2_m.new).as_plan + plan.add(t1) + _, errors = syskit_engine.resolve_system_network( + [t1.planning_task], + capture_errors_during_network_resolution: true, + default_deployment_group: default_deployment_group, + early_deploy: true + ) + assert_equal 1, errors.size + assert_kind_of MissingDeployment, + errors.first.original_exception + end + + it "accumulate errors from generator and deployer instead of " \ + "raising them" do + not_deployed_task_m = Syskit::TaskContext.new_submodel + not_deployed_task_m.provides @srv_m, as: "srv" + @cmp_m.add @srv_m, as: "yet_another" + + t1 = @cmp_m.use("other" => not_deployed_task_m.new).as_plan + plan.add(t1) + _, errors = syskit_engine.resolve_system_network( + [t1.planning_task], + capture_errors_during_network_resolution: true, + default_deployment_group: default_deployment_group, + early_deploy: true + ) + assert_equal 2, errors.size + assert_kind_of TaskAllocationFailed, + errors.first.original_exception + assert_kind_of MissingDeployment, + errors[1].original_exception + end + + it "goes through the generation for tasks without issues even if " \ + "the generation fails for some of them" do + task2_m = Syskit::TaskContext.new_submodel + task2_m.provides @srv_m, as: "srv" + syskit_stub_configured_deployment(task2_m) + t1 = @cmp_m.use("test" => @task_m.new(arg: 1)).as_plan + t2 = @cmp_m.use("test" => @task_m.new(arg: 1), + "other" => task2_m.new).as_plan + plan.add(t1) + plan.add(t2) + required_instances, errors = syskit_engine.resolve_system_network( + [t1.planning_task, t2.planning_task], + capture_errors_during_network_resolution: true, + default_deployment_group: default_deployment_group, + early_deploy: true + ) + assert_equal 1, errors.size + assert_kind_of TaskAllocationFailed, + errors.first.original_exception + + assert_equal [t2.planning_task], required_instances.keys + end + end + end end class EngineTestStubDeployment < Roby::Task diff --git a/test/network_generation/test_system_network_deployer.rb b/test/network_generation/test_system_network_deployer.rb index 13f45e677..b441b1931 100644 --- a/test/network_generation/test_system_network_deployer.rb +++ b/test/network_generation/test_system_network_deployer.rb @@ -496,6 +496,24 @@ def make_candidates(count) parents = e.parents assert_equal [["test", parent]], parents end + + it "capture errors instead of raising them" do + error_handler = ResolutionErrorHandler.new( + deployer.plan, deployer.merge_solver + ) + task_m = Syskit::TaskContext.new_submodel + d0 = syskit_stub_deployment_model task_m, "task0" + deployer.default_deployment_group + .use_deployment(d0 => "test0_") + deployer.default_deployment_group + .use_deployment(d0 => "test1_") + + plan.add(parent = task_m.new) + parent.depends_on(task_m.new, role: "test") + deployer.validate_deployed_network(error_handler: error_handler) + assert error_handler.resolution_failures.any? do |f| + f.original_exception.kind_of? MissingDeployment + end end end @@ -539,6 +557,25 @@ def make_candidates(count) MESSAGE assert_equal expected, PP.pp(e, +"") end + + it "capture errors instead of raising them" do + error_handler = ResolutionErrorHandler.new( + deployer.plan, deployer.merge_solver + ) + task_m = Syskit::TaskContext.new_submodel + deployment_m.orogen_model.task "task", task_m.orogen_model + plan.add( + deployment = deployment_m.new( + name_mappings: { "task" => "task" } + ) + ) + plan.add(task = deployment.task("task")) + task.conf = %w[default something] + deployer.validate_deployed_network(error_handler: error_handler) + assert error_handler.resolution_failures.any? do |f| + f.original_exception.kind_of? MissingConfigurationSection + end + end end end diff --git a/test/network_generation/test_system_network_generator.rb b/test/network_generation/test_system_network_generator.rb index a87619ebc..4dc060734 100644 --- a/test/network_generation/test_system_network_generator.rb +++ b/test/network_generation/test_system_network_generator.rb @@ -220,6 +220,258 @@ def arg=(value) end end end + + describe "resolve_system_network with error capture" do + attr_reader :error_handler, :generator + + before do + Syskit.conf.capture_errors_during_network_resolution = true + end + + after do + Syskit.conf.capture_errors_during_network_resolution = false + end + + it "capture errors regarding device allocation conflicts" do + device_m = Device.new_submodel(name: "D") + task_m = TaskContext.new_submodel(name: "T") + task_m.argument :arg + task_m.driver_for device_m, as: "test" + robot = Robot::RobotDefinition.new + robot.device device_m, as: "test" + cmp_m = Syskit::Composition.new_submodel + cmp_m.add task_m.new(arg: 1), as: "t1" + cmp_m.add task_m.new(arg: 2), as: "t2" + t1 = cmp_m + .use("t1" => task_m.new(arg: 1, test_dev: robot.test_dev), + "t2" => task_m.new(arg: 2, test_dev: robot.test_dev)) + .as_plan + plan.add(t1) + generator = make_generator + requirement_tasks = [t1.planning_task] + toplevel_tasks, errors = resolve_system_network(requirement_tasks) + + errors = assert_relevant_errors( + errors, ConflictingDeviceAllocation, size: 2 + ) + + expected_message = <<~PP + device 'test' of type D is assigned to two tasks that cannot be merged + Chain 1 cannot be merged in chain 2: + Chain 1: + T + no owners + arguments: + arg: 2, + test_dev: MasterDeviceInstance(test[D]_dev), + conf: ["default"], + read_only: false + Chain 2: + T + no owners + arguments: + arg: 1, + test_dev: MasterDeviceInstance(test[D]_dev), + conf: ["default"], + read_only: false + T(arg: 2, conf: ["default"], read_only: false, test_dev: device(D, as: test)) is needed by the following definitions: + #.use(t1 => T(arg: 1, conf: ["default"], read_only: false, test_dev: device(D, as: test)), t2 => T(arg: 2, conf: ["default"], read_only: false, test_dev: device(D, as: test))) + T(arg: 1, conf: ["default"], read_only: false, test_dev: device(D, as: test)) is needed by the following definitions: + #.use(t1 => T(arg: 1, conf: ["default"], read_only: false, test_dev: device(D, as: test)), t2 => T(arg: 2, conf: ["default"], read_only: false, test_dev: device(D, as: test))) + PP + errors.each do |err| + assert_exception(err, requirement_tasks.first.planned_task, + expected_message) + # formatted = formatted.gsub(//, "") + # .gsub(/Class:0x[0-9a-f]+/, + # "Class:0xXXXXXX") + end + end + + it "capture errors regarding missing device allocation" do + srv_m = Syskit::DataService.new_submodel + device_m = Device.new_submodel(name: "D") + device_m.provides srv_m + + task_m = TaskContext.new_submodel(name: "T") + task_m.argument :arg + task_m.driver_for device_m, as: "test" + cmp_m = Syskit::Composition.new_submodel + cmp_m.add task_m.new(arg: 1), as: "task" + + plan.add(task1 = cmp_m.as_plan) + generator = make_generator + requirement_tasks = [task1.planning_task] + toplevel_tasks, errors = resolve_system_network(requirement_tasks) + + error = + assert_relevant_errors(errors, DeviceAllocationFailed).first + expected_message = <<~MSG + cannot find a device to tie to a task + for T(arg: 1, conf: ["default"], read_only: false) + child task of (conf: []) + no candidates for T:test + MSG + assert_exception(error, requirement_tasks.first.planned_task, + expected_message) + end + + it "capture errors related to task allocation" do + srv_m = Syskit::DataService.new_submodel + cmp_m = Syskit::Composition.new_submodel + cmp_m.add srv_m, as: "task" + + plan.add(task1 = cmp_m.as_plan) + generator = make_generator + requirement_tasks = [task1.planning_task] + toplevel_tasks, errors = resolve_system_network( + requirement_tasks, garbage_collect: false + ) + + error = assert_relevant_errors(errors, TaskAllocationFailed).first + expected_message = <<~MSG + cannot find a concrete implementation for 1 task(s) + Models::Placeholder<#<#:0xXXXXXX>>() + no candidates + child task of (conf: []) + MSG + assert_exception(error, requirement_tasks.first.planned_task, + expected_message) + # assert_equal expected, + # formatted.gsub(//, "") + # .gsub(/Class:0x[0-9a-f]+>:0x[0-9a-f]+>/, + # "Class:0xXXXXXX>:0xXXXXXX>") + end + + it "capture errors related to missing deployments" do + skip unless Syskit.conf.early_deploy? + + task_m = Syskit::TaskContext.new_submodel(name: "T") + task_m.argument :arg + + cmp_m = Syskit::Composition.new_submodel + cmp_m.add task_m.new(arg: 1), as: "task" + + plan.add(task1 = cmp_m.as_plan) + generator = make_generator + + generator.merge_solver + .merge_task_contexts_with_same_agent = true + requirement_tasks = [task1.planning_task] + toplevel_tasks, errors = resolve_system_network(requirement_tasks) + + expected_message = <<~MSG + cannot deploy the following task + T(arg: 1, conf: ["default"], read_only: false) () + child task of (conf: []) + T(arg: 1, conf: ["default"], read_only: false): no deployments available + MSG + error = assert_relevant_errors(errors, MissingDeployment).first + assert_exception(error, requirement_tasks.first.planned_task, + expected_message) + end + + it "capture errors related to conflicting deployment allocations" do + skip unless Syskit.conf.early_deploy? + + task_m = Syskit::TaskContext.new_submodel(name: "T") + task_m.argument :arg + + cmp_m = Syskit::Composition.new_submodel + cmp_m.add task_m, as: "task" + + syskit_stub_configured_deployment(task_m, "task1") + + task1 = cmp_m.use("task" => task_m.with_arguments(arg: 1)).as_plan + task2 = cmp_m.use("task" => task_m.with_arguments(arg: 2)).as_plan + plan.add(task1) + plan.add(task2) + generator = make_generator + + generator.merge_solver + .merge_task_contexts_with_same_agent = true + requirement_tasks = [task1, task2].map(&:planning_task) + toplevel_tasks, errors = resolve_system_network(requirement_tasks) + + errors = assert_relevant_errors( + errors, ConflictingDeploymentAllocation, size: 2 + ) + + expected_message = <<~MSG + deployed task 'task1' from deployment 'task1' defined in '' on 'stubs' is assigned to 2 tasks. Below is the list of the dependent non-deployed actions. Right after the list is a detailed explanation of why the first two tasks are not merged: + Chain 1 cannot be merged in chain 2: + Chain 1: + T + no owners + arguments: + orocos_name: "task1", + read_only: false, + conf: ["default"], + arg: 1 + Chain 2: + T + no owners + arguments: + orocos_name: "task1", + read_only: false, + conf: ["default"], + arg: 2 + MSG + errors.zip(requirement_tasks).each do |error, task| + assert_exception(error, task.planned_task, expected_message) + end + end + + def assert_exception(error, expected_task, expected_message) + assert_equal expected_task, error.planned_task + + exception = error.original_exception + formatted = PP.pp(exception, +"") + assert_equal expected_message, + formatted.gsub(//, "") + .gsub(/Class:0x[0-9a-f]+>:0x[0-9a-f]+>/, + "Class:0xXXXXXX>:0xXXXXXX>") + .gsub(/Class:0x[0-9a-f]+/, "Class:0xXXXXXX") + end + + def resolve_system_network(requirement_tasks, garbage_collect: true) + instance_requirements = requirement_tasks.map(&:requirements) + toplevel_tasks, errors = execute do + toplevel_tasks = generator.instanciate_system_network( + instance_requirements + ) + generator.resolve_system_network( + garbage_collect: garbage_collect + ) + required_instances = + Hash[requirement_tasks.zip(toplevel_tasks)] + errors = error_handler.process_failures( + required_instances, cleanup_failed_tasks: true + ) + [toplevel_tasks, errors] + end + end + + def assert_relevant_errors(errors, error_type, size: 1) + errors = errors.find_all do |e| + e.original_exception.kind_of? error_type + end + assert_equal size, errors.size + errors + end + + def make_generator + work_plan = Roby::Transaction.new(plan) + @merge_solver = MergeSolver.new(work_plan) + @error_handler = + ResolutionErrorHandler.new(work_plan, @merge_solver) + @generator = SystemNetworkGenerator.new( + work_plan, + error_handler: error_handler, + merge_solver: @merge_solver, + early_deploy: Syskit.conf.early_deploy?, + default_deployment_group: default_deployment_group + ) end end end diff --git a/test/runtime/test_apply_requirement_modifications.rb b/test/runtime/test_apply_requirement_modifications.rb index f9b8c80ab..2ec45c8cc 100644 --- a/test/runtime/test_apply_requirement_modifications.rb +++ b/test/runtime/test_apply_requirement_modifications.rb @@ -5,6 +5,17 @@ module Syskit module Runtime describe ".apply_requirement_modifications" do + before do + @__capture_errors_feature_flag = + Syskit.conf.capture_errors_during_network_resolution? + Syskit.conf.capture_errors_during_network_resolution = false + end + + after do + Syskit.conf.capture_errors_during_network_resolution = + @__capture_errors_feature_flag + end + it "does nothing by default" do Runtime.apply_requirement_modifications(plan) refute plan.syskit_current_resolution @@ -139,6 +150,59 @@ module Runtime requirement_task.failed_event.last.context.first ) end + + describe "capture_errors" do + before do + @__capture_errors_feature_flag = + Syskit.conf.capture_errors_during_network_resolution? + Syskit.conf.capture_errors_during_network_resolution = true + end + + after do + Syskit.conf.capture_errors_during_network_resolution = + @__capture_errors_feature_flag + end + + it "applies the computed network for the well-defined instance tasks " \ + "and fails with an error for badly-defined instance tasks" do + task_m = TaskContext.new_submodel + cmp_m = Composition.new_submodel + req_task1 = + plan.add_permanent_task(task_m.to_instance_requirements.as_plan) + req_task2 = + plan.add_permanent_task(cmp_m.to_instance_requirements.as_plan) + requirement_tasks = [req_task1, req_task2].map(&:planning_task) + execute do + requirement_tasks.each(&:start!) + end + execute { Runtime.apply_requirement_modifications(plan) } + plan.syskit_current_resolution.future.value + expect_execution { Runtime.apply_requirement_modifications(plan) } + .to { have_error_matching Roby::PlanningFailedError } + + req_task1, req_task2 = requirement_tasks + + assert req_task2.success? + + assert req_task1.failed? + exceptions = req_task1.failed_event.last.context.first + assert_equal 1, exceptions.size + assert_kind_of Syskit::MissingDeployment, exceptions.first + assert_exception_can_be_pretty_printed(exceptions.first) + end + + it "applies the computed network and emits the planning task's success " \ + "event" do + cmp_m = Composition.new_submodel + requirement_task = cmp_m.to_instance_requirements.as_plan + plan.add_permanent_task(requirement_task) + requirement_task = requirement_task.planning_task + execute { requirement_task.start! } + execute { Runtime.apply_requirement_modifications(plan) } + plan.syskit_current_resolution.future.value + execute { Runtime.apply_requirement_modifications(plan) } + assert requirement_task.success? + end end def assert_resolution_cancelled # rubocop:disable Metrics/AbcSize From 109eee6bf69ff685cf935688681dc58ae6ae3fa0 Mon Sep 17 00:00:00 2001 From: Jhonas Date: Wed, 29 Jan 2025 15:29:48 -0300 Subject: [PATCH 4/6] fix: adapt network manipulation to deal with possible captured errors Whenever resolution errors are reported, it will emit failed for each individual task AND raise PartialNetworkResolution since the profile assertions require an exception to fail --- lib/syskit/test/network_manipulation.rb | 42 ++++++++++++++++--------- lib/syskit/test/profile_assertions.rb | 6 ++++ lib/syskit/test/stub_network.rb | 5 ++- test/test/test_network_manipulation.rb | 14 +++++++-- 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/lib/syskit/test/network_manipulation.rb b/lib/syskit/test/network_manipulation.rb index c45743a7d..b7577317f 100644 --- a/lib/syskit/test/network_manipulation.rb +++ b/lib/syskit/test/network_manipulation.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "roby/standard_errors" require "syskit/test/stubs" require "syskit/test/stub_network" require "syskit/test/instance_requirement_planning_handler" @@ -160,11 +161,21 @@ def syskit_generate_network(*to_instanciate, add_missions: true) end task_mapping = plan.in_transaction do |trsc| engine = NetworkGeneration::Engine.new(plan, work_plan: trsc) - mapping = engine.compute_system_network( + mapping, resolution_errors = engine.compute_system_network( tasks_to_instanciate.map(&:planning_task), validate_generated_network: false, early_deploy: false ) + unless resolution_errors.empty? + resolution_errors.each do |error| + t = error.planned_task + expect_execution do + t.failed_event.emit(error.original_exceptions) + end + end + + raise NetworkGeneration::PartialNetworkResolution, resolution_errors + end trsc.commit_transaction mapping end @@ -227,7 +238,7 @@ def syskit_deploy_normalized_placeholder_tasks( .to { emit(*not_running.map(&:start_event)) } resolve_options = Hash[on_error: :commit].merge(resolve_options) - begin + resolution_errors = execute do syskit_engine_resolve_handle_plan_export do syskit_engine ||= Syskit::NetworkGeneration::Engine.new(plan) syskit_engine.resolve( @@ -235,23 +246,24 @@ def syskit_deploy_normalized_placeholder_tasks( **resolve_options ) end - rescue StandardError => e - expect_execution do - requirement_tasks.each { |t| t.failed_event.emit(e) } - end.to do + end + if resolution_errors.empty? + execute do + placeholder_tasks.each do |task| + plan.remove_task(task) + end requirement_tasks.each do |t| - have_error_matching(Roby::PlanningFailedError - .match.with_origin(t.planned_task)) + t.success_event.emit unless t.finished? end end - raise - end - - execute do - placeholder_tasks.each do |task| - plan.remove_task(task) + else + resolution_errors.each do |error| + t = error.planned_task + expect_execution do + t.failed_event.emit(error.original_exception) + end end - requirement_tasks.each { |t| t.success_event.emit unless t.finished? } + raise NetworkGeneration::PartialNetworkResolution, resolution_errors end end diff --git a/lib/syskit/test/profile_assertions.rb b/lib/syskit/test/profile_assertions.rb index 9838cdd13..6ff91c8d8 100644 --- a/lib/syskit/test/profile_assertions.rb +++ b/lib/syskit/test/profile_assertions.rb @@ -341,6 +341,9 @@ def assert_can_instanciate_together(*actions) syskit_run_deploy_in_bulk( actions, compute_policies: false, compute_deployments: false ) + rescue NetworkGeneration::PartialNetworkResolution => e + raise ProfileAssertionFailed.new("configure together", actions, + e.original_exceptions) rescue Minitest::Assertion, StandardError => e raise ProfileAssertionFailed.new("instanciate together", actions, e), e.message, e.backtrace @@ -439,6 +442,9 @@ def assert_can_deploy_together(*actions) syskit_run_deploy_in_bulk( actions, compute_policies: true, compute_deployments: true ) + rescue NetworkGeneration::PartialNetworkResolution => e + raise ProfileAssertionFailed.new("configure together", actions, + e.original_exceptions) rescue Minitest::Assertion, StandardError => e raise ProfileAssertionFailed.new("deploy together", actions, e), e.message, e.backtrace diff --git a/lib/syskit/test/stub_network.rb b/lib/syskit/test/stub_network.rb index c38f0ada6..94da843ab 100644 --- a/lib/syskit/test/stub_network.rb +++ b/lib/syskit/test/stub_network.rb @@ -105,10 +105,13 @@ def apply_in_transaction(trsc, root_tasks, remote_task: false) trsc.static_garbage_collect( protected_roots: trsc_new_roots | trsc_other_tasks ) + error_handler = NetworkGeneration::RaiseErrorHandler.new NetworkGeneration::SystemNetworkGenerator .verify_task_allocation( - trsc, components: trsc_tasks.find_all(&:plan) + trsc, error_handler: error_handler, + components: trsc_tasks.find_all(&:plan) ) + mapped_tasks end diff --git a/test/test/test_network_manipulation.rb b/test/test/test_network_manipulation.rb index fc6450d70..3086c835d 100644 --- a/test/test/test_network_manipulation.rb +++ b/test/test/test_network_manipulation.rb @@ -233,13 +233,23 @@ module Test assert_equal "test_level", task.orocos_name end - it "on error, it changes the planning failed and mission failed " \ + it "on error, it filters out the planning failed and mission failed " \ "error caused by itself" do task_m = Syskit::TaskContext.new_submodel assert_raises(Syskit::MissingDeployment) do syskit_deploy(task_m) end end + + it "when capturing errors, it changes the planning failed error into a" \ + "PartialNetworkResolution" do + before_flag = Syskit.conf.capture_errors_during_network_resolution? + Syskit.conf.capture_errors_during_network_resolution = true + task_m = Syskit::TaskContext.new_submodel + assert_raises(Syskit::NetworkGeneration::PartialNetworkResolution) do + syskit_deploy(task_m) + end + Syskit.conf.capture_errors_during_network_resolution = before_flag end end @@ -377,7 +387,7 @@ module Test end it "fails if the plan can't be deployed" do - plan.add_mission_task(@cmp_m.as_plan) + plan.add_mission_task(@cmp_m.use("srv" => @task_m).as_plan) assert_raises(Roby::Test::ExecutionExpectations::UnexpectedErrors) do deploy_current_plan end From 4eeefa193d6d9b05138b0830860d6471a34e0f1f Mon Sep 17 00:00:00 2001 From: Jhonas Date: Wed, 29 Jan 2025 15:29:48 -0300 Subject: [PATCH 5/6] fix: adapt doc and gui code after error capture The error capture introduces a slight change in the engine interface, so we need to adapt these so they keep working as before. --- lib/syskit/cli/doc/gen.rb | 2 +- lib/syskit/gui/component_network_base_view.rb | 5 +++-- lib/syskit/gui/component_network_view.rb | 10 ++++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/syskit/cli/doc/gen.rb b/lib/syskit/cli/doc/gen.rb index 71c8914d5..02f8b2604 100644 --- a/lib/syskit/cli/doc/gen.rb +++ b/lib/syskit/cli/doc/gen.rb @@ -266,7 +266,7 @@ def self.compute_system_network(model, main_plan = Roby::Plan.new, **options) main_plan.add(original_task = model.as_plan) engine = Syskit::NetworkGeneration::Engine.new(main_plan) planning_task = original_task.planning_task - mapping = engine.compute_system_network([planning_task], **options) + mapping, = engine.compute_system_network([planning_task], **options) if engine.work_plan.respond_to?(:commit_transaction) engine.work_plan.commit_transaction diff --git a/lib/syskit/gui/component_network_base_view.rb b/lib/syskit/gui/component_network_base_view.rb index 75fcf2375..798f5b4f4 100644 --- a/lib/syskit/gui/component_network_base_view.rb +++ b/lib/syskit/gui/component_network_base_view.rb @@ -149,8 +149,9 @@ def compute_system_network(model, main_plan = nil) main_plan.add(original_task = model.as_plan) base_task = original_task.as_service engine = Syskit::NetworkGeneration::Engine.new(main_plan) - engine.compute_system_network([base_task.task.planning_task]) - base_task.task + _, resolution_errors = + engine.compute_system_network([base_task.task.planning_task]) + [base_task.task, resolution_errors] ensure if engine && engine.work_plan.respond_to?(:commit_transaction) engine.work_plan.commit_transaction diff --git a/lib/syskit/gui/component_network_view.rb b/lib/syskit/gui/component_network_view.rb index fdb0ff68b..7cc756daa 100644 --- a/lib/syskit/gui/component_network_view.rb +++ b/lib/syskit/gui/component_network_view.rb @@ -150,11 +150,17 @@ def render(model, begin if method == :compute_system_network tic = Time.now - @task = compute_system_network(model, plan) + @task, resolution_errors = compute_system_network(model, plan) + exception = NetworkGeneration::PartialNetworkResolution.new( + resolution_errors + ) timing = Time.now - tic elsif method == :compute_deployed_network tic = Time.now - @task = compute_deployed_network(model, plan) + @task, resolution_errors = compute_deployed_network(model, plan) + exception = NetworkGeneration::PartialNetworkResolution.new( + resolution_errors + ) timing = Time.now - tic else @task = instanciate_model(model, plan, instanciate_options) From 3b564b1df9e292814a648d3aeabaafbcca4272a9 Mon Sep 17 00:00:00 2001 From: Jhonas Date: Wed, 29 Jan 2025 15:29:48 -0300 Subject: [PATCH 6/6] fix: add more information on the network generation application result Now that we can capture errors, we have to add more information on the result of the application of the generated network to the plan. This is done by changing the return of #syskit_apply_resolution_results to a struct that has information of the errors and the planned instances. --- lib/syskit/network_generation/async.rb | 3 --- .../network_generation/resolution_error_handler.rb | 8 ++++++++ .../network_generation/system_network_generator.rb | 2 +- lib/syskit/runtime/apply_requirement_modifications.rb | 11 ++++++++--- .../test/instance_requirement_planning_handler.rb | 8 ++++---- testfile | 0 6 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 testfile diff --git a/lib/syskit/network_generation/async.rb b/lib/syskit/network_generation/async.rb index 67404a980..a78d98594 100644 --- a/lib/syskit/network_generation/async.rb +++ b/lib/syskit/network_generation/async.rb @@ -119,9 +119,6 @@ def cancelled? class InvalidState < RuntimeError; end - SystemNetworkPlanApplyResult = - Struct.new :fulfilled, :instances, :errors, keyword_init: true - # Apply the result of the generation # # @return [Boolean] true if the result has been applied, and false diff --git a/lib/syskit/network_generation/resolution_error_handler.rb b/lib/syskit/network_generation/resolution_error_handler.rb index 1145aa55c..040157cf5 100644 --- a/lib/syskit/network_generation/resolution_error_handler.rb +++ b/lib/syskit/network_generation/resolution_error_handler.rb @@ -11,6 +11,14 @@ def initialize(errors) end end + # Reports the result of the network generation application. + SystemNetworkPlanApplyResult = + Struct.new :fulfilled, :instances, :errors, keyword_init: true do + def fulfilled? + fulfilled + end + end + # This is used to capture failures during the network generation process. Each # failure bundles the toplevel tasks that are related to the task that originates # the failure. It should be transformed into a ResolutionError for each related diff --git a/lib/syskit/network_generation/system_network_generator.rb b/lib/syskit/network_generation/system_network_generator.rb index 39552a50a..59541f749 100644 --- a/lib/syskit/network_generation/system_network_generator.rb +++ b/lib/syskit/network_generation/system_network_generator.rb @@ -497,7 +497,7 @@ def self.verify_all_deployments_are_unique( # # It performs the tests that are only needed on an abstract network, # i.e. on a network in which some tasks are still abstract - def validate_abstract_network + def validate_abstract_network(error_handler: @error_handler) self.class.verify_no_multiplexing_connections(plan) super if defined? super end diff --git a/lib/syskit/runtime/apply_requirement_modifications.rb b/lib/syskit/runtime/apply_requirement_modifications.rb index 102d9eeea..1390e0c1e 100644 --- a/lib/syskit/runtime/apply_requirement_modifications.rb +++ b/lib/syskit/runtime/apply_requirement_modifications.rb @@ -103,7 +103,10 @@ def syskit_apply_async_resolution_results begin resolution_apply_result = syskit_current_resolution.apply - return unless resolution_apply_result.fulfilled + + unless resolution_apply_result.fulfilled? + return resolution_apply_result + end ensure syskit_current_resolution_keepalive.discard_transaction @syskit_current_resolution = nil @@ -115,12 +118,14 @@ def syskit_apply_async_resolution_results resolution_apply_result.errors.group_by(&:planning_task).each do |task, e| task.failed_event.emit e.flat_map(&:original_exception) end - nil + resolution_apply_result rescue ::Exception => e # rubocop:disable Lint/RescueException running_requirement_tasks.each do |t| t.failed_event.emit(e) end - e + NetworkGeneration::SystemNetworkPlanApplyResult.new( + fulfilled: false, errors: [e] + ) end end diff --git a/lib/syskit/test/instance_requirement_planning_handler.rb b/lib/syskit/test/instance_requirement_planning_handler.rb index ff3f9b93f..3cbba2517 100644 --- a/lib/syskit/test/instance_requirement_planning_handler.rb +++ b/lib/syskit/test/instance_requirement_planning_handler.rb @@ -56,17 +56,17 @@ def apply_requirements ) end - def finished? + def finished? # rubocop:disable Metrics/AbcSize Thread.pass if @plan.syskit_has_async_resolution? return unless @plan.syskit_finished_async_resolution? - error = @plan.syskit_apply_async_resolution_results - return true if error + resolution_results = @plan.syskit_apply_async_resolution_results + return true unless resolution_results.fulfilled? return unless @test.syskit_run_planner_stub? - root_tasks = @planning_tasks.map(&:planned_task) + root_tasks = resolution_results.instances.keys.map(&:planned_task) stub_network = StubNetwork.new(@test) # NOTE: this is a run-planner equivalent to syskit_stub_network diff --git a/testfile b/testfile new file mode 100644 index 000000000..e69de29bb