Created
November 1, 2012 12:47
-
-
Save kizzx2/3993442 to your computer and use it in GitHub Desktop.
Attach debugger to arbitrary Ruby process. Almost working
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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