Skip to content

Instantly share code, notes, and snippets.

@kizzx2
Created November 1, 2012 12:47
Show Gist options
  • Save kizzx2/3993442 to your computer and use it in GitHub Desktop.
Save kizzx2/3993442 to your computer and use it in GitHub Desktop.
Attach debugger to arbitrary Ruby process. Almost working
require 'tempfile'
require 'debugger'
DEBUGGER_MONKEY_PATCH = <<'EOF'
module Debugger
class << self
def stop_remote
return unless @thread || @control_thread
@stopped = true
@control_thread = nil
@thread = nil
end
#
# Starts a remote debugger.
#
def start_remote(host = nil, port = PORT)
return if @thread
@stopped = false
self.interface = nil
start
if port.kind_of?(Array)
cmd_port, ctrl_port = port
else
cmd_port, ctrl_port = port, port + 1
end
start_control(host, ctrl_port)
yield if block_given?
mutex = Mutex.new
proceed = ConditionVariable.new
@thread = DebugThread.new do
server = TCPServer.new(host, cmd_port)
# Periodically check whether we should stop
begin
session = server.accept_nonblock
self.interface = RemoteInterface.new(session)
if wait_connection
mutex.synchronize do
proceed.signal
end
end
rescue IO::WaitReadable
IO.select([server], nil, nil, 1)
retry unless @stopped
end
end
if wait_connection
mutex.synchronize do
proceed.wait(mutex)
end
end
end
def start_control(host = nil, ctrl_port = PORT + 1) # :nodoc:
return if defined?(@control_thread) && @control_thread
@control_thread = DebugThread.new do
server = TCPServer.new(host, ctrl_port)
# Periodically check whether we should stop
begin
session = server.accept
interface = RemoteInterface.new(session)
processor = ControlCommandProcessor.new(interface)
processor.process_commands
rescue IO::WaitReadable
IO.select([server], nil, nil, 1)
retry unless @stopped
end
end
end
end
end
EOF
def print_help
puts "attach.rb by Chris Yuen <chris@kizzx2.com> 2012"
puts "Usage: attach.rb <PID>"
end
def ensure_dependencies
`gdb -v`
raise "Could not execute GDB" unless $?.success?
end
def dependency_lib_paths
%w(debugger debugger-linecache columnize).map do |dep|
gem = Gem::Specification.find_by_name(dep)
File.join(gem.gem_dir, gem.require_paths.first)
end
end
def random_open_port
s = TCPServer.new('127.0.0.1', 0)
port = s.addr[1]
s.close
port
end
class RemoteEvaluator < Struct.new(:pid)
def eval(command)
# We go through this dance of using signals to avoid having problems with
# IO locks or whatnot. Setting signal traps seeems to be fine but anything
# more complicated than that will upset a Ruby VM that is in the middle of a
# syscall
wrapped_command = %($__attach_rb_prev_sig_handler__ = trap('#{SIG}') { #{command} })
gdb_rb_eval(wrapped_command)
Process.kill(SIG, self.pid)
# gdb_rb_eval %(trap('#{SIG}', &$__attach_rb_prev_sig_handler__))
end
SIG = "USR1"
private
# Run a Ruby command through GDB and then exit
def gdb_rb_eval(command)
raise ArgumentError, "Command should not contain double quote" if command.include?('"')
raise ArgumentError, "Command should be in one line" if command.lines.count > 1
Tempfile.open('attach.rb') do |temp|
temp.puts(%(call (int)rb_eval_string_protect("#{command}", 0)))
temp.puts("detach")
temp.puts("q")
temp.flush
`gdb -p #{self.pid} -x #{temp.path}`
raise "Error while executing Ruby command \`#{command}\` via GDB" unless $?.success?
end
end
end
if ARGV.count != 1
print_help
exit 1
end
ensure_dependencies
pid = ARGV[0].to_i
Process.getpgid(pid) rescue fail "Could not find process ##{pid}"
port = random_open_port
target = RemoteEvaluator.new(pid)
remote_ready = false
trap(RemoteEvaluator::SIG) do
remote_ready = true
end
Tempfile.open("attach_rb_monkey_patch") do |temp|
temp.write(DEBUGGER_MONKEY_PATCH)
temp.flush
start_command = ""
dependency_lib_paths.each do |path|
start_command << "$: << '#{path}' unless $:.include?('#{path}')\n"
end
start_command << <<-COMMAND
require 'debugger'
load '#{temp.path}' unless Debugger.respond_to?(:stop_remote)
Debugger.stop_remote
Debugger.start_remote(nil, #{port})
Process.kill('#{RemoteEvaluator::SIG}', #{Process.pid})
COMMAND
start_command = start_command.lines.map(&:chomp).join(";")
target.eval(start_command)
end
puts "Starting remote debugger in process ##{pid} on port #{port}..."
until remote_ready
sleep 0.5
end
t = Thread.new do
Debugger.start_client('localhost', port)
end
# A hardcoded time to prevent our luser from shooting himself in the foot by jamming SIGINT
sleep 1
trap('INT') { target.eval %(Thread.new { require 'pry-remote'; binding.remote_pry }) }
# Throw the ball of interprocess synchronization to the end user...
puts "SIGINT (Ctrl-C) to break."
puts "EOF (Ctrl-D) in debugger line to detach."
puts
puts "Warning: Be gentle. Jamming SIGINT may yield undefined behavior."
loop do
sleep 1
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment