Skip to content

Commit b5020e7

Browse files
committed
Support multi-process debugging: sync breakpoints and coordinate instances
Two fixes for debugging multi-process Ruby applications: 1. Breakpoint synchronization across forked processes (fixes #714): Store serialized breakpoint specs in a shared JSON tempfile alongside the existing flock tempfile. Publish on subsession leave, check on subsession enter, and in the socket reader retry paths for both DAP and console protocols. Breakpoints define to_sync_data for serialization. Only LineBreakpoint and CatchBreakpoint are synced. 2. Coordination of independent debugger instances: When parallel test runners fork workers before the debugger loads, each worker gets its own SESSION with no coordination. Add a well-known lock file keyed by process group ID (/tmp/ruby-debug-{uid}-pgrp-{getpgrp}.lock) that all sibling instances discover automatically. On enter_subsession, acquire the lock (blocking flock) so only one process enters the debugger at a time. While blocked, no prompt is shown and IRB/Reline never reads STDIN.
1 parent bad4d38 commit b5020e7

File tree

4 files changed

+148
-1
lines changed

4 files changed

+148
-1
lines changed

lib/debug/breakpoint.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module DEBUGGER__
66
class Breakpoint
77
include SkipPathHelper
88

9-
attr_reader :key, :skip_src
9+
attr_reader :key, :skip_src, :cond
1010

1111
def initialize cond, command, path, do_enable: true
1212
@deleted = false
@@ -19,6 +19,12 @@ def initialize cond, command, path, do_enable: true
1919
enable if do_enable
2020
end
2121

22+
# Returns a serializable hash for cross-process breakpoint sync,
23+
# or nil if this breakpoint type is not syncable.
24+
def to_sync_data
25+
nil
26+
end
27+
2228
def safe_eval b, expr
2329
b.eval(expr)
2430
rescue Exception => e
@@ -221,6 +227,12 @@ def activate_exact iseq, events, line
221227
end
222228
end
223229

230+
def to_sync_data
231+
{ 'type' => 'line', 'path' => @path, 'line' => @line,
232+
'cond' => @cond, 'oneshot' => @oneshot,
233+
'hook_call' => @hook_call, 'command' => @command }
234+
end
235+
224236
def duplicable?
225237
@oneshot
226238
end
@@ -302,6 +314,10 @@ def path_is? path
302314
class CatchBreakpoint < Breakpoint
303315
attr_reader :last_exc
304316

317+
def to_sync_data
318+
{ 'type' => 'catch', 'pat' => @pat, 'cond' => @cond }
319+
end
320+
305321
def initialize pat, cond: nil, command: nil, path: nil
306322
@pat = pat.freeze
307323
@key = [:catch, @pat].freeze

lib/debug/server.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ def process
186186
line = @session.process_group.sync do
187187
unless IO.select([@sock], nil, nil, 0)
188188
DEBUGGER__.debug{ "UI_Server can not read" }
189+
# Wait briefly for the consuming process to publish breakpoint changes
190+
sleep 0.05
191+
@session.bp_sync_check
189192
break :can_not_read
190193
end
191194
@sock.gets&.chomp.tap{|line|

lib/debug/server_dap.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,10 @@ def recv_request
271271
end
272272
end
273273
rescue RetryBecauseCantRead
274+
# Another process consumed the message. Wait briefly for it to
275+
# process and publish any breakpoint changes, then sync.
276+
sleep 0.05
277+
@session.bp_sync_check
274278
retry
275279
end
276280

@@ -356,6 +360,7 @@ def process_request req
356360
bps << SESSION.add_line_breakpoint(path, line)
357361
end
358362
}
363+
SESSION.bp_sync_publish
359364
send_response req, breakpoints: (bps.map do |bp| {verified: true,} end)
360365
else
361366
send_response req, breakpoints: (args['breakpoints'].map do |bp| {verified: false, message: "#{req_path} could not be located; specify source location in launch.json with \"localfsMap\" or \"localfs\""} end)
@@ -391,6 +396,7 @@ def process_request req
391396
process_filter.call(bp_info['filterId'], bp_info['condition'])
392397
}
393398

399+
SESSION.bp_sync_publish
394400
send_response req, breakpoints: filters
395401

396402
when 'disconnect'

lib/debug/session.rb

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242

4343
require 'json' if ENV['RUBY_DEBUG_TEST_UI'] == 'terminal'
4444
require 'pp'
45+
require 'set'
4546

4647
class RubyVM::InstructionSequence
4748
def traceable_lines_norec lines
@@ -90,10 +91,77 @@ module DEBUGGER__
9091

9192
class PostmortemError < RuntimeError; end
9293

94+
module BreakpointSync
95+
def bp_sync_publish
96+
return unless @process_group.multi?
97+
@process_group.write_breakpoint_state(serialize_sync_breakpoints)
98+
end
99+
100+
def bp_sync_check
101+
return false unless @process_group.multi?
102+
specs = @process_group.read_breakpoint_state
103+
return false unless specs
104+
reconcile_breakpoints(specs)
105+
true
106+
end
107+
108+
private
109+
110+
def serialize_sync_breakpoints
111+
@bps.filter_map { |_key, bp| bp.to_sync_data }
112+
end
113+
114+
def reconcile_breakpoints(specs)
115+
remote_keys = Set.new
116+
117+
specs.each do |spec|
118+
key = bp_key_from_spec(spec)
119+
next unless key
120+
remote_keys << key
121+
unless @bps.key?(key)
122+
create_bp_from_spec(spec)
123+
end
124+
end
125+
126+
@bps.delete_if do |key, bp|
127+
if syncable_bp?(bp) && !remote_keys.include?(key)
128+
bp.delete
129+
true
130+
end
131+
end
132+
end
133+
134+
def bp_key_from_spec(spec)
135+
case spec['type']
136+
when 'line' then [spec['path'], spec['line']]
137+
when 'catch' then [:catch, spec['pat']]
138+
end
139+
end
140+
141+
def create_bp_from_spec(spec)
142+
bp = case spec['type']
143+
when 'line'
144+
LineBreakpoint.new(spec['path'], spec['line'],
145+
cond: spec['cond'], oneshot: spec['oneshot'],
146+
hook_call: spec['hook_call'] != false,
147+
command: spec['command'])
148+
when 'catch'
149+
CatchBreakpoint.new(spec['pat'], cond: spec['cond'])
150+
end
151+
152+
add_bp(bp) if bp
153+
end
154+
155+
def syncable_bp?(bp)
156+
bp.to_sync_data != nil
157+
end
158+
end
159+
93160
class Session
94161
attr_reader :intercepted_sigint_cmd, :process_group, :subsession_id
95162

96163
include Color
164+
include BreakpointSync
97165

98166
def initialize
99167
@ui = nil
@@ -1711,8 +1779,10 @@ def get_thread_client th = Thread.current
17111779
DEBUGGER__.debug{ "Enter subsession (nested #{@subsession_stack.size})" }
17121780
else
17131781
DEBUGGER__.debug{ "Enter subsession" }
1782+
@process_group.wk_lock # blocks until no other debugger is active
17141783
stop_all_threads
17151784
@process_group.lock
1785+
bp_sync_check # sync breakpoints from other processes
17161786
end
17171787

17181788
@subsession_stack << true
@@ -1724,7 +1794,9 @@ def get_thread_client th = Thread.current
17241794

17251795
if @subsession_stack.empty?
17261796
DEBUGGER__.debug{ "Leave subsession" }
1797+
bp_sync_publish # publish breakpoint changes to other processes
17271798
@process_group.unlock
1799+
@process_group.unlock_wk_lock
17281800
restart_all_threads
17291801
else
17301802
DEBUGGER__.debug{ "Leave subsession (nested #{@subsession_stack.size})" }
@@ -2028,6 +2100,7 @@ def extend_feature session: nil, thread_client: nil, ui: nil
20282100
class ProcessGroup
20292101
def initialize
20302102
@lock_file = nil
2103+
@wk_lock_file = nil
20312104
end
20322105

20332106
def locked?
@@ -2057,10 +2130,38 @@ def multi?
20572130
@lock_file
20582131
end
20592132

2133+
# No-ops for single-process mode; overridden by MultiProcessGroup
2134+
def write_breakpoint_state(specs); end
2135+
def read_breakpoint_state; nil; end
2136+
2137+
# Well-known lock for coordinating independent debugger instances
2138+
# (e.g., parallel test workers that each load the debugger independently).
2139+
# Uses process group ID so sibling processes from the same command share the lock.
2140+
# Blocks until the lock is acquired — other workers wait in line.
2141+
def wk_lock
2142+
return if multi? # MultiProcessGroup handles its own locking
2143+
ensure_wk_lock!
2144+
@wk_lock_file.flock(File::LOCK_EX)
2145+
end
2146+
2147+
def unlock_wk_lock
2148+
return if multi?
2149+
@wk_lock_file&.flock(File::LOCK_UN)
2150+
end
2151+
2152+
private def ensure_wk_lock!
2153+
return if @wk_lock_file
2154+
path = File.join('/tmp', "ruby-debug-#{Process.uid}-pgrp-#{Process.getpgrp}.lock")
2155+
@wk_lock_file = File.open(path, File::WRONLY | File::CREAT, 0600)
2156+
end
2157+
20602158
def multi_process!
20612159
require 'tempfile'
2160+
require 'json'
20622161
@lock_tempfile = Tempfile.open("ruby-debug-lock-")
20632162
@lock_tempfile.close
2163+
@state_tempfile = Tempfile.open("ruby-debug-state-")
2164+
@state_tempfile.close
20642165
extend MultiProcessGroup
20652166
end
20662167
end
@@ -2076,6 +2177,7 @@ def after_fork child: true
20762177
@lock_level = 0
20772178
@lock_file = open(@lock_tempfile.path, 'w')
20782179
end
2180+
@bp_sync_version = 0
20792181
end
20802182
end
20812183

@@ -2146,6 +2248,25 @@ def unlock
21462248
end
21472249
end
21482250

2251+
def write_breakpoint_state(specs)
2252+
@bp_sync_version += 1
2253+
data = JSON.generate({ 'v' => @bp_sync_version, 'bps' => specs })
2254+
tmp = "#{@state_tempfile.path}.#{Process.pid}.tmp"
2255+
File.write(tmp, data)
2256+
File.rename(tmp, @state_tempfile.path)
2257+
end
2258+
2259+
def read_breakpoint_state
2260+
return nil unless File.exist?(@state_tempfile.path)
2261+
data = JSON.parse(File.read(@state_tempfile.path))
2262+
remote_v = data['v']
2263+
return nil if remote_v <= @bp_sync_version
2264+
@bp_sync_version = remote_v
2265+
data['bps']
2266+
rescue JSON::ParserError, Errno::ENOENT
2267+
nil
2268+
end
2269+
21492270
def sync &b
21502271
info "sync"
21512272

@@ -2547,6 +2668,7 @@ def daemon(*args)
25472668
child_hook = -> {
25482669
DEBUGGER__.info "Attaching after process #{parent_pid} fork to child process #{Process.pid}"
25492670
SESSION.process_group.after_fork child: true
2671+
SESSION.bp_sync_check
25502672
SESSION.activate on_fork: true
25512673
}
25522674
end

0 commit comments

Comments
 (0)