lib/debug/thread_client.rb



# frozen_string_literal: true

require 'objspace'
require 'pp'

require_relative 'color'

class ::Thread
  attr_accessor :debug_thread_client
end

module DEBUGGER__
  M_INSTANCE_VARIABLES = method(:instance_variables).unbind
  M_INSTANCE_VARIABLE_GET = method(:instance_variable_get).unbind
  M_CLASS = method(:class).unbind
  M_SINGLETON_CLASS = method(:singleton_class).unbind
  M_KIND_OF_P = method(:kind_of?).unbind
  M_RESPOND_TO_P = method(:respond_to?).unbind
  M_METHOD = method(:method).unbind
  M_OBJECT_ID = method(:object_id).unbind
  M_NAME = method(:name).unbind

  module SkipPathHelper
    def skip_path?(path)
      !path ||
      DEBUGGER__.skip? ||
      ThreadClient.current.management? ||
      skip_internal_path?(path) ||
      skip_config_skip_path?(path)
    end

    def skip_config_skip_path?(path)
      (skip_paths = CONFIG[:skip_path]) && skip_paths.any?{|skip_path| path.match?(skip_path)}
    end

    def skip_internal_path?(path)
      path.start_with?(__dir__) || path.delete_prefix('!eval:').start_with?('<internal:')
    end

    def skip_location?(loc)
      loc_path = loc.absolute_path || "!eval:#{loc.path}"
      skip_path?(loc_path)
    end
  end

  module GlobalVariablesHelper
    SKIP_GLOBAL_LIST = %i[$= $KCODE $-K $SAFE].freeze
    def safe_global_variables
      global_variables.reject{|name| SKIP_GLOBAL_LIST.include? name }
    end
  end

  class ThreadClient
    def self.current
      Thread.current.debug_thread_client ||= SESSION.get_thread_client
    end

    include Color
    include SkipPathHelper
    include GlobalVariablesHelper

    attr_reader :thread, :id, :recorder, :check_bp_fulfillment_map

    def location
      current_frame&.location
    end

    def assemble_arguments(args)
      args.map do |arg|
        "#{colorize_cyan(arg[:name])}=#{arg[:value]}"
      end.join(", ")
    end

    def default_frame_formatter frame
      call_identifier_str =
        case frame.frame_type
        when :block
          level, block_loc = frame.block_identifier
          args = frame.parameters_info

          if !args.empty?
            args_str = " {|#{assemble_arguments(args)}|}"
          end

          "#{colorize_blue("block")}#{args_str} in #{colorize_blue(block_loc + level)}"
        when :method
          ci = frame.method_identifier
          args = frame.parameters_info

          if !args.empty?
            args_str = "(#{assemble_arguments(args)})"
          end

          "#{colorize_blue(ci)}#{args_str}"
        when :c
          colorize_blue(frame.c_identifier)
        when :other
          colorize_blue(frame.other_identifier)
        end

      location_str = colorize(frame.location_str, [:GREEN])
      result = "#{call_identifier_str} at #{location_str}"

      if return_str = frame.return_str
        result += " #=> #{colorize_magenta(return_str)}"
      end

      result
    end

    def initialize id, q_evt, q_cmd, thr = Thread.current
      @is_management = false
      @id = id
      @thread = thr
      @target_frames = nil
      @q_evt = q_evt
      @q_cmd = q_cmd
      @step_tp = nil
      @output = []
      @frame_formatter = method(:default_frame_formatter)
      @var_map = {} # { thread_local_var_id => obj } for DAP
      @obj_map = {} # { object_id => obj } for CDP
      @recorder = nil
      @mode = :waiting
      @current_frame_index = 0
      # every thread should maintain its own CheckBreakpoint fulfillment state
      @check_bp_fulfillment_map = {} # { check_bp => boolean }
      set_mode :running
      thr.instance_variable_set(:@__thread_client_id, id)

      ::DEBUGGER__.info("Thread \##{@id} is created.")
    end

    def deactivate
      @step_tp.disable if @step_tp
    end

    def management?
      @is_management
    end

    def mark_as_management
      @is_management = true
    end

    def set_mode mode
      debug_mode(@mode, mode)
      # STDERR.puts "#{@mode} => #{mode} @ #{caller.inspect}"
      # pp caller

      # mode transition check
      case mode
      when :running
        raise "#{mode} is given, but #{mode}" unless self.waiting?
      when :waiting
        # TODO: there is waiting -> waiting
        # raise "#{mode} is given, but #{mode}" unless self.running?
      else
        raise "unknown mode: #{mode}"
      end

      # DEBUGGER__.warn "#{@mode} => #{mode} @ #{self.inspect}"
      @mode = mode
    end

    def running?
      @mode == :running
    end

    def waiting?
      @mode == :waiting
    end

    def name
      "##{@id} #{@thread.name || @thread.backtrace.last}"
    end

    def close
      @q_cmd.close
    end

    def inspect
      if bt = @thread.backtrace
        "#<DBG:TC #{self.id}:#{@mode}@#{bt[-1]}>"
      else # bt can be nil
        "#<DBG:TC #{self.id}:#{@mode}>"
      end
    end

    def to_s
      str = "(#{@thread.name || @thread.status})@#{current_frame&.location || @thread.to_s}"
      str += " (not under control)" unless self.waiting?
      str
    end

    def puts str = ''
      if @recorder&.replaying?
        prefix = colorize_dim("[replay] ")
      end
      case str
      when nil
        @output << "\n"
      when Array
        str.each{|s| puts s}
      else
        @output << "#{prefix}#{str.chomp}\n"
      end
    end

    def << req
      debug_cmd(req)
      @q_cmd << req
    end

    def generate_info
      return unless current_frame

      { location: current_frame.location_str, line: current_frame.location.lineno }
    end

    def event! ev, *args
      debug_event(ev, args)
      @q_evt << [self, @output, ev, generate_info, *args]
      @output = []
    end

    ## events

    def wait_reply event_arg
      return if management?

      set_mode :waiting

      event!(*event_arg)
      wait_next_action
    end

    def on_load iseq, eval_src
      wait_reply [:load, iseq, eval_src]
    end

    def on_init name
      wait_reply [:init, name]
    end

    def on_trace trace_id, msg
      wait_reply [:trace, trace_id, msg]
    end

    def on_breakpoint tp, bp
      suspend tp.event, tp, bp: bp
    end

    def on_trap sig
      if waiting?
        # raise Interrupt
      else
        suspend :trap, sig: sig
      end
    end

    def on_pause
      suspend :pause
    end

    def suspend event, tp = nil, bp: nil, sig: nil, postmortem_frames: nil, replay_frames: nil, postmortem_exc: nil
      return if management?
      debug_suspend(event)

      @current_frame_index = 0

      case
      when postmortem_frames
        @target_frames = postmortem_frames
        @postmortem = true
      when replay_frames
        @target_frames = replay_frames
      else
        @target_frames = DEBUGGER__.capture_frames(__dir__)
      end

      cf = @target_frames.first
      if cf
        case event
        when :return, :b_return, :c_return
          cf.has_return_value = true
          cf.return_value = tp.return_value
        end

        if CatchBreakpoint === bp
          cf.has_raised_exception = true
          cf.raised_exception = bp.last_exc
        end

        if postmortem_exc
          cf.has_raised_exception = true
          cf.raised_exception = postmortem_exc
        end
      end

      if event != :pause
        unless bp&.skip_src
          show_src
          show_frames CONFIG[:show_frames]
        end

        set_mode :waiting

        if bp
          event! :suspend, :breakpoint, bp.key
        elsif sig
          event! :suspend, :trap, sig
        else
          event! :suspend, event
        end
      else
        set_mode :waiting
      end

      wait_next_action
    end

    def replay_suspend
      # @recorder.current_position
      suspend :replay, replay_frames: @recorder.current_frame
    end

    ## control all

    begin
      TracePoint.new(:raise){}.enable(target_thread: Thread.current)
      SUPPORT_TARGET_THREAD = true
    rescue ArgumentError
      SUPPORT_TARGET_THREAD = false
    end

    def step_tp iter, events = [:line, :b_return, :return]
      @step_tp.disable if @step_tp

      thread = Thread.current
      subsession_id = SESSION.subsession_id

      if SUPPORT_TARGET_THREAD
        @step_tp = TracePoint.new(*events){|tp|
          if SESSION.stop_stepping? tp.path, tp.lineno, subsession_id
            tp.disable
            next
          end
          next if !yield(tp)
          next if tp.path.start_with?(__dir__)
          next if tp.path.start_with?('<internal:trace_point>')
          next unless File.exist?(tp.path) if CONFIG[:skip_nosrc]
          loc = caller_locations(1, 1).first
          next if skip_location?(loc)
          next if iter && (iter -= 1) > 0

          tp.disable
          suspend tp.event, tp
        }
        @step_tp.enable(target_thread: thread)
      else
        @step_tp = TracePoint.new(*events){|tp|
          next if thread != Thread.current
          if SESSION.stop_stepping? tp.path, tp.lineno, subsession_id
            tp.disable
            next
          end
          next if !yield(tp)
          next if tp.path.start_with?(__dir__)
          next if tp.path.start_with?('<internal:trace_point>')
          next unless File.exist?(tp.path) if CONFIG[:skip_nosrc]
          loc = caller_locations(1, 1).first
          next if skip_location?(loc)
          next if iter && (iter -= 1) > 0

          tp.disable
          suspend tp.event, tp
        }
        @step_tp.enable
      end
    end

    ## cmd helpers

    if TracePoint.respond_to? :allow_reentry
      def tp_allow_reentry
        TracePoint.allow_reentry do
          yield
        end
      rescue RuntimeError => e
        # on the postmortem mode, it is not stopped in TracePoint
        if e.message == 'No need to allow reentrance.'
          yield
        else
          raise
        end
      end
    else
      def tp_allow_reentry
        yield
      end
    end

    def frame_eval_core src, b, binding_location: false
      saved_target_frames = @target_frames
      saved_current_frame_index = @current_frame_index

      if b
        file, lineno = b.source_location

        tp_allow_reentry do
          if binding_location
            b.eval(src, file, lineno)
          else
            b.eval(src, "(rdbg)/#{file}")
          end
        end
      else
        frame_self = current_frame.self

        tp_allow_reentry do
          frame_self.instance_eval(src)
        end
      end
    ensure
      @target_frames = saved_target_frames
      @current_frame_index = saved_current_frame_index
    end

    SPECIAL_LOCAL_VARS = [
      [:raised_exception, "_raised"],
      [:return_value,     "_return"],
    ]

    def frame_eval src, re_raise: false, binding_location: false
      @success_last_eval = false

      b = current_frame&.eval_binding || TOPLEVEL_BINDING

      special_local_variables current_frame do |name, var|
        b.local_variable_set(name, var) if /\%/ !~ name
      end

      result = frame_eval_core(src, b, binding_location: binding_location)

      @success_last_eval = true
      result

    rescue SystemExit
      raise
    rescue Exception => e
      return yield(e) if block_given?

      puts "eval error: #{e}"

      e.backtrace_locations&.each do |loc|
        break if loc.path == __FILE__
        puts "  #{loc}"
      end
      raise if re_raise
    end

    def get_src(frame,
                max_lines:,
                start_line: nil,
                end_line: nil,
                dir: +1)
      if file_lines = frame.file_lines
        frame_line = frame.location.lineno - 1

        if CONFIG[:no_lineno]
          lines = file_lines
        else
          lines = file_lines.map.with_index do |e, i|
            cur = i == frame_line ? '=>' : '  '
            line = colorize_dim('%4d|' % (i+1))
            "#{cur}#{line} #{e}"
          end
        end

        unless start_line
          if frame.show_line
            if dir > 0
              start_line = frame.show_line
            else
              end_line = frame.show_line - max_lines
              start_line = [end_line - max_lines, 0].max
            end
          else
            start_line = [frame_line - max_lines/2, 0].max
          end
        end

        unless end_line
          end_line = [start_line + max_lines, lines.size].min
        end

        if start_line != end_line && max_lines
          [start_line, end_line, lines]
        end
      else # no file lines
        nil
      end
    rescue Exception => e
      p e
      pp e.backtrace
      exit!
    end

    def show_src(frame_index: @current_frame_index, update_line: false, ignore_show_line: false, max_lines: CONFIG[:show_src_lines], **options)
      if frame = get_frame(frame_index)
        begin
          if ignore_show_line
            prev_show_line = frame.show_line
            frame.show_line = nil
          end

          start_line, end_line, lines = *get_src(frame, max_lines: max_lines, **options)

          if start_line
            if update_line
              frame.show_line = end_line
            end

            puts "[#{start_line+1}, #{end_line}] in #{frame.pretty_path}" if !update_line && max_lines != 1
            puts lines[start_line...end_line]
          else
            puts "# No sourcefile available for #{frame.path}"
          end
        ensure
          frame.show_line = prev_show_line if prev_show_line
        end
      end
    end

    def current_frame
      get_frame(@current_frame_index)
    end

    def get_frame(index)
      if @target_frames
        @target_frames[index]
      else
        nil
      end
    end

    def collect_locals(frame)
      locals = []

      if s = frame&.self
        locals << ["%self", s]
      end
      special_local_variables frame do |name, val|
        locals << [name, val]
      end

      if vars = frame&.local_variables
        vars.each{|var, val|
          locals << [var, val]
        }
      end

      locals
    end

    ## cmd: show

    def special_local_variables frame
      SPECIAL_LOCAL_VARS.each do |mid, name|
        next unless frame&.send("has_#{mid}")
        name = name.sub('_', '%') if frame.eval_binding.local_variable_defined?(name)
        yield name, frame.send(mid)
      end
    end

    def show_locals pat
      collect_locals(current_frame).each do |var, val|
        puts_variable_info(var, val, pat)
      end
    end

    def show_ivars pat, expr = nil
      if expr && !expr.empty?
        _self = frame_eval(expr);
      elsif _self = current_frame&.self
      else
        _self = nil
      end

      if _self
        M_INSTANCE_VARIABLES.bind_call(_self).sort.each{|iv|
          value = M_INSTANCE_VARIABLE_GET.bind_call(_self, iv)
          puts_variable_info iv, value, pat
        }
      end
    end

    def iter_consts c, names = {}
      c.constants(false).sort.each{|name|
        next if names.has_key? name
        names[name] = nil
        begin
          value = c.const_get(name)
        rescue Exception => e
          value = e
        end
        yield name, value
      }
    end

    def get_consts expr = nil, only_self: false, &block
      if expr && !expr.empty?
        begin
          _self = frame_eval(expr, re_raise: true)
        rescue Exception
          # ignore
        else
          if M_KIND_OF_P.bind_call(_self, Module)
            iter_consts _self, &block
            return
          else
            puts "#{_self.inspect} (by #{expr}) is not a Module."
          end
        end
      elsif _self = current_frame&.self
        cs = {}
        if M_KIND_OF_P.bind_call(_self, Module)
          cs[_self] = :self
        else
          _self = M_CLASS.bind_call(_self)
          cs[_self] = :self unless only_self
        end

        unless only_self
          _self.ancestors.each{|c| break if c == Object; cs[c] = :ancestors}
          if b = current_frame&.binding
            b.eval('::Module.nesting').each{|c| cs[c] = :nesting unless cs.has_key? c}
          end
        end

        names = {}

        cs.each{|c, _|
          iter_consts c, names, &block
        }
      end
    end

    def show_consts pat, expr = nil, only_self: false
      get_consts expr, only_self: only_self do |name, value|
        puts_variable_info name, value, pat
      end
    end

    def show_globals pat
      safe_global_variables.sort.each{|name|
        next if SKIP_GLOBAL_LIST.include? name

        value = eval(name.to_s)
        puts_variable_info name, value, pat
      }
    end

    def puts_variable_info label, obj, pat
      return if pat && pat !~ label

      begin
        inspected = DEBUGGER__.safe_inspect(obj)
      rescue Exception => e
        inspected = e.inspect
      end
      mono_info = "#{label} = #{inspected}"

      w = SESSION::width

      if mono_info.length >= w
        maximum_value_width = w - "#{label} = ".length
        valstr = truncate(inspected, width: maximum_value_width)
      else
        valstr = colored_inspect(obj, width: 2 ** 30)
        valstr = inspected if valstr.lines.size > 1
      end

      info = "#{colorize_cyan(label)} = #{valstr}"

      puts info
    end

    def truncate(string, width:)
      if string.start_with?("#<")
        string[0 .. (width-5)] + '...>'
      else
        string[0 .. (width-4)] + '...'
      end
    end

    ### cmd: show edit

    def show_by_editor path = nil
      unless path
        if current_frame
          path = current_frame.path
        else
          return # can't get path
        end
      end

      if File.exist?(path)
        if editor = (ENV['RUBY_DEBUG_EDITOR'] || ENV['EDITOR'])
          puts "command: #{editor}"
          puts "   path: #{path}"
          require 'shellwords'
          system(*Shellwords.split(editor), path)
        else
          puts "can not find editor setting: ENV['RUBY_DEBUG_EDITOR'] or ENV['EDITOR']"
        end
      else
        puts "Can not find file: #{path}"
      end
    end

    ### cmd: show frames

    def show_frames max = nil, pattern = nil
      if @target_frames && (max ||= @target_frames.size) > 0
        frames = []
        @target_frames.each_with_index{|f, i|
          # we need to use FrameInfo#matchable_location because #location_str is for display
          # and it may change based on configs (e.g. use_short_path)
          next if pattern && !(f.name.match?(pattern) || f.matchable_location.match?(pattern))
          # avoid using skip_path? because we still want to display internal frames
          next if skip_config_skip_path?(f.matchable_location)

          frames << [i, f]
        }

        size = frames.size
        max.times{|i|
          break unless frames[i]
          index, frame = frames[i]
          puts frame_str(index, frame: frame)
        }
        puts "  # and #{size - max} frames (use `bt' command for all frames)" if max < size
      end
    end

    def show_frame i=0
      puts frame_str(i)
    end

    def frame_str(i, frame: @target_frames[i])
      cur_str = (@current_frame_index == i ? '=>' : '  ')
      prefix = "#{cur_str}##{i}"
      frame_string = @frame_formatter.call(frame)
      "#{prefix}\t#{frame_string}"
    end

    ### cmd: show outline

    def show_outline expr
      begin
        obj = frame_eval(expr, re_raise: true)
      rescue Exception
        # ignore
      else
        o = Output.new(@output)

        locals = current_frame&.local_variables

        klass = M_CLASS.bind_call(obj)
        klass = obj if Class == klass || Module == klass

        o.dump("constants", obj.constants) if M_RESPOND_TO_P.bind_call(obj, :constants)
        outline_method(o, klass, obj)
        o.dump("instance variables", M_INSTANCE_VARIABLES.bind_call(obj))
        o.dump("class variables", klass.class_variables)
        o.dump("locals", locals.keys) if locals
      end
    end

    def outline_method(o, klass, obj)
      begin
        singleton_class = M_SINGLETON_CLASS.bind_call(obj)
      rescue TypeError
        singleton_class = nil
      end

      maps = class_method_map((singleton_class || klass).ancestors)
      maps.each do |mod, methods|
        name = mod == singleton_class ? "#{klass}.methods" : "#{mod}#methods"
        o.dump(name, methods)
      end
    end

    def class_method_map(classes)
      dumped = Array.new
      classes.reject { |mod| mod >= Object }.map do |mod|
        methods = mod.public_instance_methods(false).select do |m|
          dumped.push(m) unless dumped.include?(m)
        end
        [mod, methods]
      end.reverse
    end

    ## cmd: breakpoint

    # TODO: support non-ASCII Constant name
    def constant_name? name
      case name
      when /\A::\b/
        constant_name? $~.post_match
      when /\A[A-Z]\w*/
        post = $~.post_match
        if post.empty?
          true
        else
          constant_name? post
        end
      else
        false
      end
    end

    def make_breakpoint args
      case args.first
      when :method
        klass_name, op, method_name, cond, cmd, path = args[1..]
        bp = MethodBreakpoint.new(current_frame&.eval_binding || TOPLEVEL_BINDING, klass_name, op, method_name, cond: cond, command: cmd, path: path)
        begin
          bp.enable
        rescue NameError => e
          if bp.klass
            puts "Unknown method name: \"#{e.name}\""
          else
            # klass_name can not be evaluated
            if constant_name? klass_name
              puts "Unknown constant name: \"#{e.name}\""
            else
              # only Class name is allowed
              puts "Not a constant name: \"#{klass_name}\""
              bp = nil
            end
          end

          Session.activate_method_added_trackers if bp
        rescue Exception => e
          puts e.inspect
          bp = nil
        end

        bp
      when :watch
        ivar, object, result, cond, command, path = args[1..]
        WatchIVarBreakpoint.new(ivar, object, result, cond: cond, command: command, path: path)
      else
        raise "unknown breakpoint: #{args}"
      end
    end

    class SuspendReplay < Exception
    end

    if ::Fiber.respond_to?(:blocking)
      private def fiber_blocking
        ::Fiber.blocking{yield}
      end
    else
      private def fiber_blocking
        yield
      end
    end

    def wait_next_action
      fiber_blocking{wait_next_action_}
    rescue SuspendReplay
      replay_suspend
    end

    def wait_next_action_
      # assertions
      raise "@mode is #{@mode}" if !waiting?

      unless SESSION.active?
        pp caller
        set_mode :running
        return
      end

      while true
        begin
          set_mode :waiting if !waiting?
          cmds = @q_cmd.pop
          # pp [self, cmds: cmds]

          break unless cmds
        ensure
          set_mode :running
        end

        cmd, *args = *cmds

        case cmd
        when :continue
          break

        when :step
          step_type = args[0]
          iter = args[1]

          case step_type
          when :in
            iter = iter || 1
            if @recorder&.replaying?
              @recorder.step_forward iter
              raise SuspendReplay
            else
              step_tp iter do
                true
              end
              break
            end

          when :next
            frame = @target_frames.first
            path = frame.location.absolute_path || "!eval:#{frame.path}"
            line = frame.location.lineno
            label = frame.location.base_label

            if frame.iseq
              frame.iseq.traceable_lines_norec(lines = {})
              next_line = lines.keys.bsearch{|e| e > line}
              if !next_line && (last_line = frame.iseq.last_line) > line
                next_line = last_line
              end
            end

            depth = @target_frames.first.frame_depth

            step_tp iter do |tp|
              loc = caller_locations(2, 1).first
              loc_path = loc.absolute_path || "!eval:#{loc.path}"
              loc_label = loc.base_label
              loc_depth = DEBUGGER__.frame_depth - 3

              case
              when loc_depth == depth && loc_label == label
                true
              when loc_depth < depth
                # lower stack depth
                true
              when (next_line &&
                    loc_path == path &&
                    (loc_lineno = loc.lineno) > line &&
                    loc_lineno <= next_line)
                # different frame (maybe block) but the line is before next_line
                true
              end
            end
            break

          when :finish
            finish_frames = (iter || 1) - 1
            frame = @target_frames.first
            goal_depth = frame.frame_depth - finish_frames - (frame.has_return_value ? 1 : 0)

            step_tp nil, [:return, :b_return] do
              DEBUGGER__.frame_depth - 3 <= goal_depth ? true : false
            end
            break

          when :until
            location = iter&.strip
            frame = @target_frames.first
            depth = frame.frame_depth - (frame.has_return_value ? 1 : 0)
            target_location_label = frame.location.base_label

            case location
            when nil, /\A(?:(.+):)?(\d+)\z/
              no_loc = !location
              file = $1 || frame.location.path
              line = ($2 ||  frame.location.lineno + 1).to_i

              step_tp nil, [:line, :return] do |tp|
                if tp.event == :line
                  next false if no_loc && depth < DEBUGGER__.frame_depth - 3
                  next false unless tp.path.end_with?(file)
                  next false unless tp.lineno >= line
                  true
                else
                  true if depth >= DEBUGGER__.frame_depth - 3 &&
                          caller_locations(2, 1).first.label == target_location_label
                          # TODO: imcomplete condition
                end
              end
            else
              pat = location
              if /\A\/(.+)\/\z/ =~ pat
                pat = Regexp.new($1)
              end

              step_tp nil, [:call, :c_call, :return] do |tp|
                case tp.event
                when :call, :c_call
                  true if pat === tp.callee_id.to_s
                else # :return, :b_return
                  true if depth >= DEBUGGER__.frame_depth - 3 &&
                          caller_locations(2, 1).first.label == target_location_label
                          # TODO: imcomplete condition
                end
              end
            end

            break

          when :back
            iter = iter || 1
            if @recorder&.can_step_back?
              unless @recorder.backup_frames
                @recorder.backup_frames = @target_frames
              end
              @recorder.step_back iter
              raise SuspendReplay
            else
              puts "Can not step back more."
              event! :result, nil
            end

          when :reset
            if @recorder&.replaying?
              @recorder.step_reset
              raise SuspendReplay
            end

          else
            raise "unknown: #{type}"
          end

        when :eval
          eval_type, eval_src = *args

          result_type = nil

          case eval_type
          when :p
            result = frame_eval(eval_src)
            puts "=> " + color_pp(result, 2 ** 30)
            if alloc_path = ObjectSpace.allocation_sourcefile(result)
              puts "allocated at #{alloc_path}:#{ObjectSpace.allocation_sourceline(result)}"
            end
          when :pp
            result = frame_eval(eval_src)
            puts color_pp(result, SESSION.width)
            if alloc_path = ObjectSpace.allocation_sourcefile(result)
              puts "allocated at #{alloc_path}:#{ObjectSpace.allocation_sourceline(result)}"
            end
          when :call
            result = frame_eval(eval_src)
          when :irb
            require_relative "irb_integration"
            activate_irb_integration
          when :display, :try_display
            failed_results = []
            eval_src.each_with_index{|src, i|
              result = frame_eval(src){|e|
                failed_results << [i, e.message]
                "<error: #{e.message}>"
              }
              puts "#{i}: #{src} = #{result}"
            }

            result_type = eval_type
            result = failed_results
          else
            raise "unknown error option: #{args.inspect}"
          end

          event! :result, result_type, result
        when :frame
          type, arg = *args
          case type
          when :up
            if @current_frame_index + 1 < @target_frames.size
              @current_frame_index += 1
              show_src max_lines: 1
              show_frame(@current_frame_index)
            end
          when :down
            if @current_frame_index > 0
              @current_frame_index -= 1
              show_src max_lines: 1
              show_frame(@current_frame_index)
            end
          when :set
            if arg
              index = arg.to_i
              if index >= 0 && index < @target_frames.size
                @current_frame_index = index
              else
                puts "out of frame index: #{index}"
              end
            end
            show_src max_lines: 1
            show_frame(@current_frame_index)
          else
            raise "unsupported frame operation: #{arg.inspect}"
          end

          event! :result, nil

        when :show
          type = args.shift

          case type
          when :backtrace
            max_lines, pattern = *args
            show_frames max_lines, pattern

          when :list
            show_src(update_line: true, **(args.first || {}))

          when :whereami
            show_src ignore_show_line: true
            show_frames CONFIG[:show_frames]

          when :edit
            show_by_editor(args.first)

          when :default
            pat = args.shift
            show_locals pat
            show_ivars  pat
            show_consts pat, only_self: true

          when :locals
            pat = args.shift
            show_locals pat

          when :ivars
            pat = args.shift
            expr = args.shift
            show_ivars pat, expr

          when :consts
            pat = args.shift
            expr = args.shift
            show_consts pat, expr

          when :globals
            pat = args.shift
            show_globals pat

          when :outline
            show_outline args.first || 'self'

          else
            raise "unknown show param: " + [type, *args].inspect
          end

          event! :result, nil

        when :breakpoint
          case args[0]
          when :method
            bp = make_breakpoint args
            event! :result, :method_breakpoint, bp
          when :watch
            ivar, cond, command, path = args[1..]
            result = frame_eval(ivar)

            if @success_last_eval
              object =
                if b = current_frame.binding
                  b.receiver
                else
                  current_frame.self
                end
              bp = make_breakpoint [:watch, ivar, object, result, cond, command, path]
              event! :result, :watch_breakpoint, bp
            else
              event! :result, nil
            end
          end

        when :trace
          case args.shift
          when :object
            begin
              obj = frame_eval args.shift, re_raise: true
              opt = args.shift
              obj_inspect = DEBUGGER__.safe_inspect(obj)

              width = 50

              if obj_inspect.length >= width
                obj_inspect = truncate(obj_inspect, width: width)
              end

              event! :result, :trace_pass, M_OBJECT_ID.bind_call(obj), obj_inspect, opt
            rescue => e
              puts e.message
              event! :result, nil
            end
          else
            raise "unreachable"
          end

        when :record
          case args[0]
          when nil
            # ok
          when :on
            # enable recording
            if !@recorder
              @recorder = Recorder.new
            end
            @recorder.enable
          when :off
            if @recorder&.enabled?
              @recorder.disable
            end
          else
            raise "unknown: #{args.inspect}"
          end

          if @recorder&.enabled?
            puts "Recorder for #{Thread.current}: on (#{@recorder.log.size} records)"
          else
            puts "Recorder for #{Thread.current}: off"
          end
          event! :result, nil

        when :quit
          sleep # wait for SystemExit
        when :dap
          process_dap args
        when :cdp
          process_cdp args
        else
          raise [cmd, *args].inspect
        end
      end

    rescue SuspendReplay, SystemExit, Interrupt
      raise
    rescue Exception => e
      STDERR.puts e.cause.inspect
      STDERR.puts e.inspect
      Thread.list.each{|th|
        STDERR.puts "@@@ #{th}"
        th.backtrace.each{|b|
          STDERR.puts " > #{b}"
        }
      }
      p ["DEBUGGER Exception: #{__FILE__}:#{__LINE__}", e, e.backtrace]
      raise
    ensure
      @returning = false
    end

    def debug_event(ev, args)
      DEBUGGER__.debug{
        args = args.map { |arg| DEBUGGER__.safe_inspect(arg) }
        "#{inspect} sends Event { type: #{ev.inspect}, args: #{args} } to Session"
      }
    end

    def debug_mode(old_mode, new_mode)
      DEBUGGER__.debug{
        "#{inspect} changes mode (#{old_mode} -> #{new_mode})"
      }
    end

    def debug_cmd(cmds)
      DEBUGGER__.debug{
        cmd, *args = *cmds
        args = args.map { |arg| DEBUGGER__.safe_inspect(arg) }
        "#{inspect} receives Cmd { type: #{cmd.inspect}, args: #{args} } from Session"
      }
    end

    def debug_suspend(event)
      DEBUGGER__.debug{
        "#{inspect} is suspended for #{event.inspect}"
      }
    end

    class Recorder
      attr_reader :log, :index
      attr_accessor :backup_frames

      include SkipPathHelper

      def initialize
        @log = []
        @index = 0
        @backup_frames = nil
        thread = Thread.current

        @tp_recorder ||= TracePoint.new(:line){|tp|
          next unless Thread.current == thread
          # can't be replaced by skip_location
          next if skip_internal_path?(tp.path)
          loc = caller_locations(1, 1).first
          next if skip_location?(loc)

          frames = DEBUGGER__.capture_frames(__dir__)
          frames.each{|frame|
            if b = frame.binding
              frame.binding = nil
              frame._local_variables = b.local_variables.map{|name|
                [name, b.local_variable_get(name)]
              }.to_h
              frame._callee = b.eval('__callee__')
            end
          }
          append(frames)
        }
      end

      def append frames
        @log << frames
      end

      def enable
        unless @tp_recorder.enabled?
          @log.clear
          @tp_recorder.enable
        end
      end

      def disable
        if @tp_recorder.enabled?
          @log.clear
          @tp_recorder.disable
        end
      end

      def enabled?
        @tp_recorder.enabled?
      end

      def step_back iter
        @index += iter
        if @index > @log.size
          @index = @log.size
        end
      end

      def step_forward iter
        @index -= iter
        if @index < 0
          @index = 0
        end
      end

      def step_reset
        @index = 0
        @backup_frames = nil
      end

      def replaying?
        @index > 0
      end

      def can_step_back?
        log.size > @index
      end

      def log_index
        @log.size - @index
      end

      def current_frame
        if @index == 0
          f = @backup_frames
          @backup_frames = nil
          f
        else
          frames = @log[log_index]
          frames
        end
      end

      # for debugging
      def current_position
        puts "INDEX: #{@index}"
        li = log_index
        @log.each_with_index{|frame, i|
          loc = frame.first&.location
          prefix = i == li ? "=> " : '   '
          puts "#{prefix} #{loc}"
        }
      end
    end

    # copied from irb
    class Output
      include Color

      MARGIN = "  "

      def initialize(output)
        @output = output
        @line_width = screen_width - MARGIN.length # right padding
      end

      def dump(name, strs)
        strs = strs.sort
        return if strs.empty?

        line = "#{colorize_blue(name)}: "

        # Attempt a single line
        if fits_on_line?(strs, cols: strs.size, offset: "#{name}: ".length)
          line += strs.join(MARGIN)
          @output << line
          return
        end

        # Multi-line
        @output << line

        # Dump with the largest # of columns that fits on a line
        cols = strs.size
        until fits_on_line?(strs, cols: cols, offset: MARGIN.length) || cols == 1
          cols -= 1
        end
        widths = col_widths(strs, cols: cols)
        strs.each_slice(cols) do |ss|
          @output << ss.map.with_index { |s, i| "#{MARGIN}%-#{widths[i]}s" % s }.join
        end
      end

      private

      def fits_on_line?(strs, cols:, offset: 0)
        width = col_widths(strs, cols: cols).sum + MARGIN.length * (cols - 1)
        width <= @line_width - offset
      end

      def col_widths(strs, cols:)
        cols.times.map do |col|
          (col...strs.size).step(cols).map do |i|
            strs[i].length
          end.max
        end
      end

      def screen_width
        SESSION.width
      rescue Errno::EINVAL # in `winsize': Invalid argument - <STDIN>
        80
      end
    end
    private_constant :Output
  end
end