BallAerospace/COSMOS

View on GitHub
cosmos/lib/cosmos/top_level.rb

Summary

Maintainability
C
1 day
Test Coverage
# encoding: ascii-8bit

# Copyright 2022 Ball Aerospace & Technologies Corp.
# All Rights Reserved.
#
# This program is free software; you can modify and/or redistribute it
# under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation; version 3 with
# attribution addendums as found in the LICENSE.txt
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# This program may also be used under the terms of a commercial or
# enterprise edition license of COSMOS if purchased from the
# copyright holder

# This file contains top level functions in the Cosmos namespace

require 'thread'
require 'digest'
require 'open3'
require 'cosmos/core_ext'
require 'cosmos/version'
require 'cosmos/utilities/logger'
require 'socket'
require 'pathname'

$cosmos_chdir_mutex = Mutex.new

# If a hazardous command is sent through the {Cosmos::Api} this error is raised.
# {Cosmos::Script} rescues the error and prompts the user to continue.
class HazardousError < StandardError
  attr_accessor :target_name
  attr_accessor :cmd_name
  attr_accessor :cmd_params
  attr_accessor :hazardous_description
  attr_accessor :formatted # formatted command for use in resending original

  def to_s
    string = "#{target_name} #{cmd_name} with #{cmd_params} is Hazardous"
    string << "due to '#{hazardous_description}'" if hazardous_description
    # Pass along the original formatted command so it can be resent
    string << ".\n#{formatted}"
  end
end

# The Ball Aerospace COSMOS system is almost
# wholly contained within the Cosmos module. COSMOS also extends some of the
# core Ruby classes to add additional functionality.

module Cosmos
  BASE_PWD = Dir.pwd

  # FatalErrors cause an exit but are not as dangerous as other errors.
  # They are used for known issues and thus we don't need a full error report.
  class FatalError < StandardError; end

  # Global mutex for the Cosmos module
  COSMOS_MUTEX = Mutex.new

  # Path to COSMOS Gem based on location of top_level.rb
  PATH = File.expand_path(File.join(File.dirname(__FILE__), '../..'))
  PATH.freeze

  # Header to put on all marshal files created by COSMOS
  COSMOS_MARSHAL_HEADER = "ruby #{RUBY_VERSION} (#{RUBY_RELEASE_DATE} patchlevel #{RUBY_PATCHLEVEL}) [#{RUBY_PLATFORM}] COSMOS #{COSMOS_VERSION}"

  # Disables the Ruby interpreter warnings such as when redefining a constant
  def self.disable_warnings
    saved_verbose = $VERBOSE
    $VERBOSE = nil
    yield
  ensure
    $VERBOSE = saved_verbose
  end

  # Adds a path to the global Ruby search path
  #
  # @param path [String] Directory path
  def self.add_to_search_path(path, front = true)
    path = File.expand_path(path)
    $:.delete(path)
    if front
      $:.unshift(path)
    else # Back
      $: << path
    end
  end

  # Creates a marshal file by serializing the given obj
  #
  # @param marshal_filename [String] Name of the marshal file to create
  # @param obj [Object] The object to serialize to the file
  def self.marshal_dump(marshal_filename, obj)
    File.open(marshal_filename, 'wb') do |file|
      file.write(COSMOS_MARSHAL_HEADER)
      file.write(Marshal.dump(obj))
    end
  rescue Exception => exception
    begin
      File.delete(marshal_filename)
    rescue Exception
      # Oh well - we tried
    end
    if exception.class == TypeError and exception.message =~ /Thread::Mutex/
      original_backtrace = exception.backtrace
      exception = exception.exception("Mutex exists in a packet.  Note: Packets must not be read during class initializers for Conversions, Limits Responses, etc.: #{exception}")
      exception.set_backtrace(original_backtrace)
    end
    self.handle_fatal_exception(exception)
  end

  # Loads the marshal file back into a Ruby object
  #
  # @param marshal_filename [String] Name of the marshal file to load
  def self.marshal_load(marshal_filename)
    cosmos_marshal_header = nil
    data = nil
    File.open(marshal_filename, 'rb') do |file|
      cosmos_marshal_header = file.read(COSMOS_MARSHAL_HEADER.length)
      data = file.read
    end
    if cosmos_marshal_header == COSMOS_MARSHAL_HEADER
      return Marshal.load(data)
    else
      Logger.warn "Marshal load failed with invalid marshal file: #{marshal_filename}"
      return nil
    end
  rescue Exception => exception
    if File.exist?(marshal_filename)
      Logger.error "Marshal load failed with exception: #{marshal_filename}\n#{exception.formatted}"
    else
      Logger.info "Marshal file does not exist: #{marshal_filename}"
    end

    # Try to delete the bad marshal file
    begin
      File.delete(marshal_filename)
    rescue Exception
      # Oh well - we tried
    end
    self.handle_fatal_exception(exception) if File.exist?(marshal_filename)
    return nil
  end

  # Executes the command in a new Ruby Thread.
  #
  # @param command [String] The command to execute via the 'system' call
  def self.run_process(command)
    thread = nil
    thread = Thread.new do
      system(command)
    end
    # Wait for the thread and process to start
    sleep 0.01 until !thread.status.nil?
    sleep 0.1
    thread
  end

  # Executes the command in a new Ruby Thread.  Will print the output if the
  # process produces any output
  #
  # @param command [String] The command to execute via the 'system' call
  def self.run_process_check_output(command)
    thread = nil
    thread = Thread.new do
      output, _ = Open3.capture2e(command)
      if !output.empty?
        # Ignore modalSession messages on Mac Mavericks
        new_output = ''
        output.each_line do |line|
          new_output << line if !/modalSession/.match?(line)
        end
        output = new_output

        if !output.empty?
          Logger.error output
          self.write_unexpected_file(output)
        end
      end
    end
    # Wait for the thread and process to start
    sleep 0.01 until !thread.status.nil?
    sleep 0.1
    thread
  end

  # Runs a hash algorithm over one or more files and returns the Digest object.
  # Handles windows/unix new line differences but changes in whitespace will
  # change the hash sum.
  #
  # Usage:
  #   digest = Cosmos.hash_files(files, additional_data, hashing_algorithm)
  #   digest.digest # => the 16 bytes of digest
  #   digest.hexdigest # => the formatted string in hex
  #
  # @param filenames [Array<String>] List of files to read and calculate a hashing
  #   sum on
  # @param additional_data [String] Additional data to add to the hashing sum
  # @param hashing_algorithm [String] Hashing algorithm to use
  # @return [Digest::<algorithm>] The hashing sum object
  def self.hash_files(filenames, additional_data = nil, hashing_algorithm = 'SHA256')
    digest = Digest.const_get(hashing_algorithm).public_send('new')

    filenames.each do |filename|
      next if File.directory?(filename)

      # Read the file's data and add to the running hashing sum
      digest << File.read(filename)
    end
    digest << additional_data if additional_data
    digest
  end

  # Opens a timestamped log file for writing. The opened file is yielded back
  # to the block.
  #
  # @param filename [String] String to append to the exception log filename.
  #   The filename will start with a date/time stamp.
  # @param log_dir [String] By default this method will write to the COSMOS
  #   default log directory. By setting this parameter you can override the
  #   directory the log will be written to.
  # @yieldparam file [File] The log file
  # @return [String|nil] The fully pathed log filename or nil if there was
  #   an error creating the log file.
  def self.create_log_file(filename, log_dir = nil)
    log_file = nil
    begin
      # The following code goes inside a begin rescue because it reads the
      # system.txt configuration file. If this has an error we won't be able
      # to determine the log path but we still want to write the log.
      log_dir = System.instance.paths['LOGS'] unless log_dir
      # Make sure the log directory exists
      raise unless File.exist?(log_dir)
    rescue Exception
      log_dir = nil # Reset log dir since it failed above
      # First check for ./logs
      log_dir = './logs' if File.exist?('./logs')
      # Prefer ./outputs/logs if it exists
      log_dir = './outputs/logs' if File.exist?('./outputs/logs')
      # If all else fails just use the local directory
      log_dir = '.' unless log_dir
    end
    log_file = File.join(log_dir,
                          File.build_timestamped_filename([filename]))
    # Check for the log file existing. This could happen if this method gets
    # called more than once in the same second.
    if File.exist?(log_file)
      sleep 1.01 # Sleep before rebuilding the timestamp to get something unique
      log_file = File.join(log_dir,
                            File.build_timestamped_filename([filename]))
    end
    begin
      COSMOS_MUTEX.synchronize do
        file = File.open(log_file, 'w')
        yield file
      ensure
        file.close unless file.closed?
        File.chmod(0444, log_file) # Make file read only
      end
    rescue Exception
      # Ensure we always return
    end
    log_file = File.expand_path(log_file)
    return log_file
  end

  # Writes a log file with information about the current configuration
  # including the Ruby version, Cosmos version, whether you are on Windows, the
  # COSMOS path, and the Ruby path along with the exception that
  # is passed in.
  #
  # @param [String] filename String to append to the exception log filename.
  #   The filename will start with a date/time stamp.
  # @param [String] log_dir By default this method will write to the COSMOS
  #   default log directory. By setting this parameter you can override the
  #   directory the log will be written to.
  # @return [String|nil] The fully pathed log filename or nil if there was
  #   an error creating the log file.
  def self.write_exception_file(exception, filename = 'exception', log_dir = nil)
    log_file = create_log_file(filename, log_dir) do |file|
      file.puts "Exception:"
      if exception
        file.puts exception.formatted
        file.puts
      else
        file.puts "No Exception Given"
        file.puts caller.join("\n")
        file.puts
      end
      file.puts "Caller Backtrace:"
      file.puts caller().join("\n")
      file.puts

      file.puts "Ruby Version: ruby #{RUBY_VERSION} (#{RUBY_RELEASE_DATE} patchlevel #{RUBY_PATCHLEVEL}) [#{RUBY_PLATFORM}]"
      file.puts "Rubygems Version: #{Gem::VERSION}"
      file.puts "Cosmos Version: #{Cosmos::VERSION}"
      file.puts "Cosmos::PATH: #{Cosmos::PATH}"
      file.puts ""
      file.puts "Environment:"
      file.puts "RUBYOPT: #{ENV['RUBYOPT']}"
      file.puts "RUBYLIB: #{ENV['RUBYLIB']}"
      file.puts "GEM_PATH: #{ENV['GEM_PATH']}"
      file.puts "GEMRC: #{ENV['GEMRC']}"
      file.puts "RI_DEVKIT: #{ENV['RI_DEVKIT']}"
      file.puts "GEM_HOME: #{ENV['GEM_HOME']}"
      file.puts "PATH: #{ENV['PATH']}"
      file.puts ""
      file.puts "Ruby Path:\n  #{$:.join("\n  ")}\n\n"
      file.puts "Gems:"
      Gem.loaded_specs.values.map { |x| file.puts "#{x.name} #{x.version} #{x.platform}" }
      file.puts ""
      file.puts "All Threads Backtraces:"
      Thread.list.each do |thread|
        file.puts thread.backtrace.join("\n")
        file.puts
      end
      file.puts ""
      file.puts ""
    ensure
      file.close
    end
    return log_file
  end

  # Writes a log file with information about unexpected output
  #
  # @param [String] text The unexpected output text
  # @param [String] filename String to append to the exception log filename.
  #   The filename will start with a date/time stamp.
  # @param [String] log_dir By default this method will write to the COSMOS
  #   default log directory. By setting this parameter you can override the
  #   directory the log will be written to.
  # @return [String|nil] The fully pathed log filename or nil if there was
  #   an error creating the log file.
  def self.write_unexpected_file(text, filename = 'unexpected', log_dir = nil)
    log_file = create_log_file(filename, log_dir) do |file|
      file.puts "Unexpected Output:\n\n"
      file.puts text
    ensure
      file.close
    end
    return log_file
  end

  # Catch fatal exceptions within the block
  # This is intended to catch exceptions before the GUI is available
  def self.catch_fatal_exception
    yield
  rescue Exception => error
    unless error.class == SystemExit or error.class == Interrupt
      Logger.level = Logger::FATAL
      Cosmos.handle_fatal_exception(error, false)
    end
  end

  # Write a message to the Logger, write an exception file, and popup a GUI
  # window if try_gui. Finally 'exit 1' is called to end the calling program.
  #
  # @param error [Exception] The exception to handle
  # @param try_gui [Boolean] Whether to try and create a GUI exception popup
  def self.handle_fatal_exception(error, try_gui = true)
    unless error.class == SystemExit or error.class == Interrupt
      $cosmos_fatal_exception = error
      self.write_exception_file(error)
      Logger.level = Logger::FATAL
      Logger.fatal "Fatal Exception! Exiting..."
      Logger.fatal error.formatted
      if $stdout != STDOUT
        $stdout = STDOUT
        Logger.fatal "Fatal Exception! Exiting..."
        Logger.fatal error.formatted
      end
      sleep 1 # Allow the messages to be printed and then crash
      exit 1
    else
      exit 0
    end
  end

  # CriticalErrors are errors that need to be brought to a user's attention but
  # do not cause an exit. A good example is if the packet log writer fails and
  # can no longer write the log file. Write a message to the Logger, write an
  # exception file, and popup a GUI window if try_gui. Ensure the GUI only
  # comes up once so this method can be called over and over by failing code.
  #
  # @param error [Exception] The exception to handle
  # @param try_gui [Boolean] Whether to try and create a GUI exception popup
  def self.handle_critical_exception(error, try_gui = true)
    Logger.error "Critical Exception! #{error.formatted}"
    self.write_exception_file(error)
  end

  # Creates a Ruby Thread to run the given block. Rescues any exceptions and
  # retries the threads the given number of times before handling the thread
  # death by calling {Cosmos.handle_fatal_exception}.
  #
  # @param name [String] Name of the thread
  # @param retry_attempts [Integer] The number of times to allow the thread to
  #   restart before exiting
  def self.safe_thread(name, retry_attempts = 0)
    Thread.new do
      retry_count = 0
      begin
        yield
      rescue => error
        Logger.error "#{name} thread unexpectedly died. Retries: #{retry_count} of #{retry_attempts}"
        Logger.error error.formatted
        retry_count += 1
        if retry_count <= retry_attempts
          self.write_exception_file(error)
          retry
        end
        handle_fatal_exception(error)
      end
    end
  end

  # Require the class represented by the filename. This uses the standard Ruby
  # convention of having a single class per file where the class name is camel
  # cased and filename is lowercase with underscores.
  #
  # @param class_name_or_class_filename [String] The name of the class or the file which contains the
  #   Ruby class to require
  # @param log_error [Boolean] Whether to log an error if we can't require the class
  def self.require_class(class_name_or_class_filename, log_error = true)
    if class_name_or_class_filename.downcase[-3..-1] == '.rb' or (class_name_or_class_filename[0] == class_name_or_class_filename[0].downcase)
      class_filename = class_name_or_class_filename
      class_name = class_filename.filename_to_class_name
    else
      class_name = class_name_or_class_filename
      class_filename = class_name.class_name_to_filename
    end
    return class_name.to_class if class_name.to_class and defined? class_name.to_class

    self.require_file(class_filename, log_error)
    klass = class_name.to_class
    raise "Ruby class #{class_name} not found" unless klass

    klass
  end

  # Requires a file with a standard error message if it fails
  #
  # @param filename [String] The name of the file to require
  # @param log_error [Boolean] Whether to log an error if we can't require the class
  def self.require_file(filename, log_error = true)
    require filename
  rescue Exception => err
    msg = "Unable to require #{filename} due to #{err.message}. "\
          "Ensure #{filename} is in the COSMOS lib directory."
    Logger.error msg if log_error
    raise $!, msg, $!.backtrace
  end

  # @param filename [String] Name of the file to open in the web browser
  def self.open_in_web_browser(filename)
    if filename
      if Kernel.is_windows?
        self.run_process("cmd /c \"start \"\" \"#{filename.gsub('/', '\\')}\"\"")
      elsif Kernel.is_mac?
        self.run_process("open -a Safari \"#{filename}\"")
      else
        which_firefox = `which firefox`.chomp
        if which_firefox =~ /Command not found/i or which_firefox =~ /no .* in/i
          raise "Firefox not found"
        else
          system_call = "#{which_firefox} \"#{filename}\""
        end

        self.run_process(system_call)
      end
    end
  end

  # Temporarily set the working directory during a block
  # Working directory is global, so this can make other threads wait
  # Ruby Dir.chdir with block always throws an error if multiple threads
  # call Dir.chdir
  def self.set_working_dir(working_dir, &block)
    if $cosmos_chdir_mutex.owned?
      set_working_dir_internal(working_dir, &block)
    else
      $cosmos_chdir_mutex.synchronize do
        set_working_dir_internal(working_dir, &block)
      end
    end
  end

  # Private helper method
  def self.set_working_dir_internal(working_dir)
    current_dir = Dir.pwd
    Dir.chdir(working_dir)
    begin
      yield
    ensure
      Dir.chdir(current_dir)
    end
  end

  # Attempt to gracefully kill a thread
  # @param owner Object that owns the thread and may have a graceful_kill method
  # @param thread The thread to gracefully kill
  # @param graceful_timeout Timeout in seconds to wait for it to die gracefully
  # @param timeout_interval How often to poll for aliveness
  # @param hard_timeout Timeout in seconds to wait for it to die ungracefully
  def self.kill_thread(owner, thread, graceful_timeout = 1, timeout_interval = 0.01, hard_timeout = 1)
    if thread
      if owner and owner.respond_to? :graceful_kill
        if Thread.current != thread
          owner.graceful_kill
          end_time = Time.now.sys + graceful_timeout
          while thread.alive? && ((end_time - Time.now.sys) > 0)
            sleep(timeout_interval)
          end
        else
          Logger.warn "Threads cannot graceful_kill themselves"
        end
      elsif owner
        Logger.info "Thread owner #{owner.class} does not support graceful_kill"
      end
      if thread.alive?
        # If the thread dies after alive? but before backtrace, bt will be nil.
        bt = thread.backtrace

        # Graceful failed
        msg =  "Failed to gracefully kill thread:\n"
        msg << "  Caller Backtrace:\n  #{caller().join("\n  ")}\n"
        msg << "  \n  Thread Backtrace:\n  #{bt.join("\n  ")}\n" if bt
        msg << "\n"
        Logger.warn msg
        thread.kill
        end_time = Time.now.sys + hard_timeout
        while thread.alive? && ((end_time - Time.now.sys) > 0)
          sleep(timeout_interval)
        end
      end
      if thread.alive?
        Logger.error "Failed to kill thread"
      end
    end
  end

  # Close a socket in a manner that ensures that any reads blocked in select
  # will unblock across platforms
  # @param socket The socket to close
  def self.close_socket(socket)
    if socket
      # Calling shutdown and then sleep seems to be required
      # to get select to reliably unblock on linux
      begin
        socket.shutdown(:RDWR)
        sleep(0)
      rescue Exception
        # Oh well we tried
      end
      begin
        socket.close unless socket.closed?
      rescue Exception
        # Oh well we tried
      end
    end
  end
end