lib/cli/kit/error_handler.rb in cli-kit-4.0.0 vs lib/cli/kit/error_handler.rb in cli-kit-5.0.0

- old
+ new

@@ -1,121 +1,182 @@ +# typed: true + require 'cli/kit' require 'English' module CLI module Kit class ErrorHandler - def initialize(log_file:, exception_reporter:, tool_name: nil) + extend T::Sig + + ExceptionReporterOrProc = T.type_alias do + T.any(T.class_of(ExceptionReporter), T.proc.returns(T.class_of(ExceptionReporter))) + end + + sig { params(override_exception_handler: T.proc.params(arg0: Exception).returns(Integer)).void } + attr_writer :override_exception_handler + + sig do + params( + log_file: T.nilable(String), + exception_reporter: ExceptionReporterOrProc, + tool_name: T.nilable(String), + dev_mode: T::Boolean, + ).void + end + def initialize(log_file: nil, exception_reporter: NullExceptionReporter, tool_name: nil, dev_mode: false) @log_file = log_file - @exception_reporter_or_proc = exception_reporter || NullExceptionReporter + @exception_reporter_or_proc = exception_reporter @tool_name = tool_name + @dev_mode = dev_mode end - module NullExceptionReporter - def self.report(_exception, _logs) - nil + class ExceptionReporter + extend T::Sig + extend T::Helpers + abstract! + + class << self + extend T::Sig + + sig { abstract.params(exception: T.nilable(Exception), logs: T.nilable(String)).void } + def report(exception, logs = nil); end end end + class NullExceptionReporter < ExceptionReporter + extend T::Sig + + class << self + extend T::Sig + + sig { override.params(_exception: T.nilable(Exception), _logs: T.nilable(String)).void } + def report(_exception, _logs = nil) + nil + end + end + end + + sig { params(block: T.proc.void).returns(Integer) } def call(&block) - install! - handle_abort(&block) + # @at_exit_exception is set if handle_abort decides to submit an error. + # $ERROR_INFO is set if we terminate because of a signal. + at_exit { report_exception(@at_exit_exception || $ERROR_INFO) } + triage_all_exceptions(&block) end - def handle_exception(error) + sig { params(error: T.nilable(Exception)).void } + def report_exception(error) if (notify_with = exception_for_submission(error)) - logs = begin - File.read(@log_file) - rescue => e - "(#{e.class}: #{e.message})" + logs = nil + if @log_file + logs = begin + File.read(@log_file) + rescue => e + "(#{e.class}: #{e.message})" + end end exception_reporter.report(notify_with, logs) end end - # maybe we can get rid of this. - attr_writer :exception + SIGNALS_THAT_ARENT_BUGS = [ + 'SIGTERM', 'SIGHUP', 'SIGINT', + ].freeze private + # Run the program, handling any errors that occur. + # + # Errors are printed to stderr unless they're #silent?, and are reported + # to bugsnag (by setting @at_exit_exeption for our at_exit handler) if + # they're #bug? + # + # Returns an exit status for the program. + sig { params(block: T.proc.void).returns(Integer) } + def triage_all_exceptions(&block) + begin + block.call + CLI::Kit::EXIT_SUCCESS + rescue Interrupt => e # Ctrl-C + # transform message, prevent bugsnag + exc = e.exception('Interrupt') + CLI::Kit.raise(exc, bug: false) + rescue Errno::ENOSPC => e + # transform message, prevent bugsnag + message = if @tool_name + "Your disk is full - {{command:#{@tool_name}}} requires free space to operate" + else + 'Your disk is full - free space is required to operate' + end + exc = e.exception(message) + CLI::Kit.raise(exc, bug: false) + end + # If SystemExit was raised, e.g. `exit()`, then + # return whatever status is attached to the exception + # object. The special exit statuses have already been + # handled below. + rescue SystemExit => e + e.status + rescue Exception => e # rubocop:disable Lint/RescueException + @at_exit_exception = e if e.bug? + + if (eh = @override_exception_handler) + return eh.call(e) + end + + raise(e) if @dev_mode && e.bug? + + stderr_puts(e.message) unless e.silent? + e.bug? ? CLI::Kit::EXIT_BUG : CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG + end + + sig { params(error: T.nilable(Exception)).returns(T.nilable(Exception)) } def exception_for_submission(error) + # happens on normal non-error termination + return(nil) if error.nil? + + return(nil) unless error.bug? + case error - when nil # normal, non-error termination - nil - when Interrupt # ctrl-c - nil - when CLI::Kit::Abort, CLI::Kit::AbortSilent # Not a bug - nil when SignalException - skip = ['SIGTERM', 'SIGHUP', 'SIGINT'] - skip.include?(error.message) ? nil : error + SIGNALS_THAT_ARENT_BUGS.include?(error.message) ? nil : error when SystemExit # "exit N" called case error.status when CLI::Kit::EXIT_SUCCESS # submit nothing if it was `exit 0` nil when CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG - # if it was `exit 30`, translate the exit code to 1, and submit nothing. - # 30 is used to signal normal failures that are not indicative of bugs. - # However, users should see it presented as 1. + # if it was `exit 30`, translate the exit code to 1, and submit + # nothing. 30 is used to signal normal failures that are not + # indicative of bugs. However, users should see it presented as 1. exit(1) else - # A weird termination status happened. `error.exception "message"` will maintain backtrace - # but allow us to set a message - error.exception("abnormal termination status: #{error.status}") + # don't treat this as an exception, simply reraise. + # this is indicative of `exit` being called with a + # non-zero number, and the requested exit status + # needs to be maintained. + exit(error.status) end else error end end - def install! - at_exit { handle_exception(@exception || $ERROR_INFO) } - end - - def handle_abort - yield - CLI::Kit::EXIT_SUCCESS - rescue CLI::Kit::GenericAbort => e - is_bug = e.is_a?(CLI::Kit::Bug) || e.is_a?(CLI::Kit::BugSilent) - is_silent = e.is_a?(CLI::Kit::AbortSilent) || e.is_a?(CLI::Kit::BugSilent) - - print_error_message(e) unless is_silent - (@exception = e) if is_bug - - CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG - rescue Interrupt - stderr_puts_message('Interrupt') - CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG - rescue Errno::ENOSPC - message = if @tool_name - "Your disk is full - {{command:#{@tool_name}}} requires free space to operate" - else - 'Your disk is full - free space is required to operate' - end - stderr_puts_message(message) - CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG - end - - def stderr_puts_message(message) - $stderr.puts(format_error_message(message)) - rescue Errno::EPIPE + sig { params(message: String).void } + def stderr_puts(message) + $stderr.puts(CLI::UI.fmt("{{red:#{message}}}")) + rescue Errno::EPIPE, Errno::EIO nil end + sig { returns(T.class_of(ExceptionReporter)) } def exception_reporter - if @exception_reporter_or_proc.respond_to?(:report) - @exception_reporter_or_proc - else + case @exception_reporter_or_proc + when Proc @exception_reporter_or_proc.call + else + @exception_reporter_or_proc end - end - - def format_error_message(msg) - CLI::UI.fmt("{{red:#{msg}}}") - end - - def print_error_message(e) - $stderr.puts(format_error_message(e.message)) end end end end