Skip to content

Commit a193e6b

Browse files
authored
Merge pull request #82 from equivalence1/ruby-attach-to-process
Ruby attach to process
2 parents ce3dbe0 + 7a15fac commit a193e6b

File tree

12 files changed

+562
-37
lines changed

12 files changed

+562
-37
lines changed

bin/gdb_wrapper

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
#!/usr/bin/env ruby
2+
3+
require 'optparse'
4+
require 'ostruct'
5+
6+
$stdout.sync = true
7+
$stderr.sync = true
8+
9+
options = OpenStruct.new(
10+
'pid' => nil,
11+
'sdk_path' => nil,
12+
'uid' => nil,
13+
'gems_to_include' => []
14+
)
15+
16+
opts = OptionParser.new do |opts|
17+
# TODO need some banner
18+
opts.banner = <<EOB
19+
Some useful banner.
20+
EOB
21+
22+
opts.on('--pid PID', 'pid of process you want to attach to for debugging') do |pid|
23+
options.pid = pid
24+
end
25+
26+
opts.on('--ruby-path RUBY_PATH', 'path to ruby interpreter') do |ruby_path|
27+
options.ruby_path = ruby_path
28+
end
29+
30+
opts.on('--uid UID', 'uid which this process should set after executing gdb attach') do |uid|
31+
options.uid = uid
32+
end
33+
34+
opts.on('--include-gem GEM_LIB_PATH', 'lib of gem to include') do |gem_lib_path|
35+
options.gems_to_include << gem_lib_path
36+
end
37+
end
38+
39+
opts.parse! ARGV
40+
41+
unless options.pid
42+
$stderr.puts 'You should specify PID of process you want to attach to'
43+
exit 1
44+
end
45+
46+
unless options.ruby_path
47+
$stderr.puts 'You should specify path to the ruby interpreter'
48+
exit 1
49+
end
50+
51+
argv = '["' + ARGV * '", "' + '"]'
52+
debugger_loader_path = File.expand_path(File.dirname(__FILE__)) + '/../lib/ruby-debug-ide/attach/debugger_loader'
53+
54+
options.gems_to_include.each do |gem_path|
55+
$LOAD_PATH.unshift(gem_path) unless $LOAD_PATH.include?(gem_path)
56+
end
57+
58+
require 'ruby-debug-ide/greeter'
59+
Debugger::print_greeting_msg(nil, nil)
60+
61+
class NativeDebugger
62+
63+
attr_reader :pid, :main_thread, :process_threads, :pipe
64+
65+
# @param executable -- path to ruby interpreter
66+
# @param pid -- pid of process you want to debug
67+
# @param flags -- flags you want to specify to your debugger as a string (e.g. "-nx -nh" for gdb to disable .gdbinit)
68+
def initialize(executable, pid, flags, gems_to_include, debugger_loader_path, argv)
69+
@pid = pid
70+
@delimiter = '__OUTPUT_FINISHED__' # for getting response
71+
@tbreak = '__func_to_set_breakpoint_at'
72+
@main_thread = nil
73+
@process_threads = nil
74+
debase_path = gems_to_include.select {|gem_path| gem_path =~ /debase/}
75+
if debase_path.size == 0
76+
raise 'No debase gem found.'
77+
end
78+
@path_to_attach = debase_path[0] + '/attach.so'
79+
80+
@gems_to_include = '["' + gems_to_include * '", "' + '"]'
81+
@debugger_loader_path = debugger_loader_path
82+
@argv = argv
83+
84+
launch_string = "#{self} #{executable} #{flags}"
85+
@pipe = IO.popen(launch_string, 'r+')
86+
$stdout.puts "executed '#{launch_string}'"
87+
end
88+
89+
def attach_to_process
90+
execute "attach #{@pid}"
91+
end
92+
93+
def execute(command)
94+
@pipe.puts command
95+
$stdout.puts "executed `#{command}` command inside #{self}."
96+
if command == 'q'
97+
return ''
98+
end
99+
get_response
100+
end
101+
102+
def get_response
103+
# we need this hack to understand that debugger gave us all output from last executed command
104+
@pipe.puts "print \"#{@delimiter}\""
105+
106+
content = ''
107+
loop do
108+
line = @pipe.readline
109+
next if line =~ /\(lldb\)/ # lldb repeats your input to its output
110+
break if line =~ /\$\d+\s=\s"#{@delimiter}"/
111+
content += line
112+
end
113+
content
114+
end
115+
116+
def update_threads
117+
118+
end
119+
120+
def check_already_under_debug
121+
122+
end
123+
124+
def switch_to_thread
125+
126+
end
127+
128+
def set_tbreak(str)
129+
execute "tbreak #{str}"
130+
end
131+
132+
def continue
133+
$stdout.puts 'continuing'
134+
@pipe.puts 'c'
135+
loop do
136+
line = @pipe.readline
137+
break if line =~ /#{Regexp.escape(@tbreak)}/
138+
end
139+
get_response
140+
end
141+
142+
def call_start_attach
143+
raise 'No main thread found. Did you forget to call `update_threads`?' if @main_thread == nil
144+
@main_thread.switch
145+
end
146+
147+
def wait_line_event
148+
call_start_attach
149+
continue
150+
end
151+
152+
def load_debugger
153+
execute "call rb_eval_string_protect(\"require '#{@debugger_loader_path}'; load_debugger(#{@gems_to_include.gsub("\"", "'")}, #{@argv.gsub("\"", "'")})\", (int *)0)"
154+
end
155+
156+
def exit
157+
execute 'q'
158+
@pipe.close
159+
end
160+
161+
def to_s
162+
'native_debugger'
163+
end
164+
165+
end
166+
167+
class LLDB < NativeDebugger
168+
169+
def initialize(executable, pid, flags, gems_to_include, debugger_loader_path, argv)
170+
super(executable, pid, flags, gems_to_include, debugger_loader_path, argv)
171+
end
172+
173+
def set_flags
174+
175+
end
176+
177+
def update_threads
178+
@process_threads = []
179+
info_threads = (execute 'thread list').split("\n")
180+
info_threads.each do |thread_info|
181+
next unless thread_info =~ /[\s*]*thread\s#\d+.*/
182+
$stdout.puts "thread_info: #{thread_info}"
183+
is_main = thread_info[0] == '*'
184+
thread_num = thread_info.sub(/[\s*]*thread\s#/, '').sub(/:\s.*$/, '').to_i
185+
thread = ProcessThread.new(thread_num, is_main, thread_info, self)
186+
if thread.is_main
187+
@main_thread = thread
188+
end
189+
@process_threads << thread
190+
end
191+
@process_threads
192+
end
193+
194+
def check_already_under_debug
195+
threads = execute 'thread list'
196+
threads =~ /ruby-debug-ide/
197+
end
198+
199+
def switch_to_thread(thread_num)
200+
execute "thread select #{thread_num}"
201+
end
202+
203+
def call_start_attach
204+
super()
205+
execute "expr (void *) dlopen(\"#{@path_to_attach}\", 2)"
206+
execute 'call start_attach()'
207+
set_tbreak(@tbreak)
208+
end
209+
210+
def to_s
211+
'lldb'
212+
end
213+
214+
end
215+
216+
class GDB < NativeDebugger
217+
218+
def initialize(executable, pid, flags, gems_to_include, debugger_loader_path, argv)
219+
super(executable, pid, flags, gems_to_include, debugger_loader_path, argv)
220+
end
221+
222+
def set_flags
223+
execute 'set scheduler-locking off' # we will deadlock with it
224+
execute 'set unwindonsignal on' # in case of some signal we will exit gdb
225+
end
226+
227+
def update_threads
228+
@process_threads = []
229+
info_threads = (execute 'info threads').split("\n")
230+
info_threads.each do |thread_info|
231+
next unless thread_info =~ /[\s*]*\d+\s+Thread.*/
232+
$stdout.puts "thread_info: #{thread_info}"
233+
is_main = thread_info[0] == '*'
234+
thread_num = thread_info.sub(/[\s*]*/, '').sub(/\s.*$/, '').to_i
235+
thread = ProcessThread.new(thread_num, is_main, thread_info, self)
236+
if thread.is_main
237+
@main_thread = thread
238+
end
239+
@process_threads << thread
240+
end
241+
@process_threads
242+
end
243+
244+
def check_already_under_debug
245+
threads = execute 'info threads'
246+
threads =~ /ruby-debug-ide/
247+
end
248+
249+
def switch_to_thread(thread_num)
250+
execute "thread #{thread_num}"
251+
end
252+
253+
def call_start_attach
254+
super()
255+
execute "call dlopen(\"#{@path_to_attach}\", 2)"
256+
execute 'call start_attach()'
257+
set_tbreak(@tbreak)
258+
end
259+
260+
def to_s
261+
'gdb'
262+
end
263+
264+
end
265+
266+
class ProcessThread
267+
268+
attr_reader :thread_num, :is_main, :thread_info, :last_bt
269+
270+
def initialize(thread_num, is_main, thread_info, native_debugger)
271+
@thread_num = thread_num
272+
@is_main = is_main
273+
@native_debugger = native_debugger
274+
@thread_info = thread_info
275+
@last_bt = nil
276+
end
277+
278+
def switch
279+
@native_debugger.switch_to_thread(thread_num)
280+
end
281+
282+
def finish
283+
@native_debugger.execute 'finish'
284+
end
285+
286+
def get_bt
287+
@last_bt = @native_debugger.execute 'bt'
288+
end
289+
290+
def any_caller_match(bt, pattern)
291+
bt =~ /#{pattern}/
292+
end
293+
294+
def is_inside_malloc(bt = get_bt)
295+
if any_caller_match(bt, '(malloc\.c)')
296+
$stderr.puts "process #{@native_debugger.pid} is currently inside malloc."
297+
true
298+
else
299+
false
300+
end
301+
end
302+
303+
def is_inside_gc(bt = get_bt)
304+
if any_caller_match(bt, '(gc\.c)')
305+
$stderr.puts "process #{@native_debugger.pid} is currently in garbage collection phase."
306+
true
307+
else
308+
false
309+
end
310+
end
311+
312+
def need_finish_frame
313+
bt = get_bt
314+
is_inside_malloc(bt) || is_inside_gc(bt)
315+
end
316+
317+
end
318+
319+
def command_exists(command)
320+
`command -v #{command} >/dev/null 2>&1 || { exit 1; }`
321+
$?.exitstatus == 0
322+
end
323+
324+
def choose_debugger(ruby_path, pid, gems_to_include, debugger_loader_path, argv)
325+
if command_exists('gdb')
326+
debugger = GDB.new(ruby_path, pid, '-nh -nx', gems_to_include, debugger_loader_path, argv)
327+
elsif command_exists('lldb')
328+
debugger = LLDB.new(ruby_path, pid, '--no-lldbinit', gems_to_include, debugger_loader_path, argv)
329+
else
330+
raise 'Neither gdb nor lldb was found. Aborting.'
331+
end
332+
333+
trap('INT') do
334+
unless debugger.pipe.closed?
335+
$stderr.puts "backtraces for threads:\n\n"
336+
debugger.process_threads.each do |thread|
337+
$stderr.puts "#{thread.thread_info}\n#{thread.last_bt}\n\n"
338+
end
339+
end
340+
exit!
341+
end
342+
343+
debugger
344+
end
345+
346+
debugger = choose_debugger(options.ruby_path, options.pid, options.gems_to_include, debugger_loader_path, argv)
347+
debugger.attach_to_process
348+
debugger.set_flags
349+
350+
if options.uid
351+
Process::Sys.setuid(options.uid.to_i)
352+
end
353+
354+
if debugger.check_already_under_debug
355+
$stderr.puts "Process #{debugger.pid} is already under debug"
356+
debugger.exit
357+
exit!
358+
end
359+
360+
should_check_threads_state = true
361+
362+
while should_check_threads_state
363+
should_check_threads_state = false
364+
debugger.update_threads.each do |thread|
365+
thread.switch
366+
while thread.need_finish_frame
367+
should_check_threads_state = true
368+
thread.finish
369+
end
370+
end
371+
end
372+
373+
debugger.wait_line_event
374+
debugger.load_debugger
375+
debugger.exit
376+
377+
sleep

0 commit comments

Comments
 (0)