Skip to content

Commit ac3f9fb

Browse files
author
Crystian Leão
committed
Add proc_regex option to filter which processes open the TCP debug port
Introduces RUBY_DEBUG_PROC_REGEX / --proc-regex=REGEX. When set, UI_TcpServer#accept matches the regex against $0. If it matches, the TCP listening port is opened normally; if it does not, accept() returns silently without opening a port (and without affecting the rest of the debugger session). Use case: forking job runners (e.g. solid_queue) where the supervisor spawns several differently-named child processes. The supervisor's startup order is non-deterministic, so --port-range from #1119 cannot target a specific worker. With --proc-regex='\Asolid-queue-worker', only processes whose $0 matches will open a port; the supervisor and non-matching workers run normally. To handle the timing window between fork and Process.setproctitle in the child, accept() waits up to 5 seconds for $0 to change from the value captured on the first accept call (tracked in InitialProcInfo) before evaluating the match. The regex is compiled once in initialize; an invalid pattern raises ArgumentError instead of being deferred to accept time. The README documents that the option is intended to be used with --nonstop (-n): without --nonstop, RUBY_DEBUG_OPEN arms an initial-suspend breakpoint that non-matching processes would still hit and block on. With --nonstop, no initial breakpoint is set and non-matching processes run unaffected. Tests cover initialize-time validation, the match path, and the no-match path with --nonstop (port not opened, program runs through).
1 parent 95997c2 commit ac3f9fb

5 files changed

Lines changed: 222 additions & 0 deletions

File tree

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,42 @@ To use TCP/IP, you can set the `RUBY_DEBUG_PORT` environment variable.
372372
$ RUBY_DEBUG_PORT=12345 ruby target.rb
373373
```
374374

375+
#### Filter processes by name with `--proc-regex`
376+
377+
In multi-process applications (e.g. forking job runners) you may want to enable
378+
the debug port only in specific processes — for example, only in workers whose
379+
title was set via `Process.setproctitle` after fork. The `--proc-regex` option
380+
(env var `RUBY_DEBUG_PROC_REGEX`) takes a regular expression that is matched
381+
against `$0` when the TCP server starts in each process. The port is opened
382+
only when it matches; otherwise the listener returns silently and the program
383+
runs without a debugger attached.
384+
385+
```console
386+
$ rdbg --port 3003 --proc-regex '\Asolid-queue-worker' --host 0.0.0.0 -n --open -c './bin/jobs'
387+
```
388+
389+
In a forking parent (the supervisor in the example) the regex will not match,
390+
so no port is opened. After each `fork`, the child re-enters the accept loop;
391+
if its `$0` (set via `Process.setproctitle`) matches, the child opens the port.
392+
393+
To handle the timing window between `fork` and `Process.setproctitle` in the
394+
child, the matcher waits up to 5 seconds for `$0` to change from the value
395+
captured on the first accept call before evaluating the match.
396+
397+
Notes and limitations:
398+
399+
- **Use `--nonstop` (`-n`).** Without it, `RUBY_DEBUG_OPEN` arms an
400+
initial-suspend breakpoint in the parent before any fork. Non-matching
401+
processes would then hit that breakpoint and block waiting for a client
402+
that will never connect. With `--nonstop`, no initial breakpoint is set
403+
and non-matching processes run unaffected.
404+
- The match is on `$0`. It does not look at `RUBY_DEBUG_FORK_MODE` or the
405+
process tree.
406+
- Independent of `--port-range`, which addresses port collisions between
407+
multiple matching processes. The two can be combined.
408+
- An invalid regex raises `ArgumentError` at startup rather than being
409+
deferred until the first connection.
410+
375411
### Integration with external debugger frontend
376412

377413
You can attach with external debugger frontend with VSCode and Chrome.
@@ -520,6 +556,7 @@ config set no_color true
520556
* `RUBY_DEBUG_PORT` (`port`): TCP/IP remote debugging: port
521557
* `RUBY_DEBUG_PORT_RANGE` (`port_range`): TCP/IP remote debugging: length of port range
522558
* `RUBY_DEBUG_HOST` (`host`): TCP/IP remote debugging: host (default: 127.0.0.1)
559+
* `RUBY_DEBUG_PROC_REGEX` (`proc_regex`): Regex to match against process name ($0); the port is opened only when it matches
523560
* `RUBY_DEBUG_SOCK_PATH` (`sock_path`): UNIX Domain Socket remote debugging: socket path
524561
* `RUBY_DEBUG_SOCK_DIR` (`sock_dir`): UNIX Domain Socket remote debugging: socket directory
525562
* `RUBY_DEBUG_LOCAL_FS_MAP` (`local_fs_map`): Specify local fs map
@@ -938,6 +975,7 @@ Debug console mode:
938975
--port=PORT Listening TCP/IP port
939976
--port-range=PORT_RANGE Number of ports to try to connect to
940977
--host=HOST Listening TCP/IP host
978+
--proc-regex=REGEX Open TCP/IP port only when $0 matches the regex
941979
--cookie=COOKIE Set a cookie for connection
942980
--session-name=NAME Session name
943981

lib/debug/config.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ module DEBUGGER__
4848
port: ['RUBY_DEBUG_PORT', "REMOTE: TCP/IP remote debugging: port"],
4949
port_range: ['RUBY_DEBUG_PORT_RANGE', "REMOTE: TCP/IP remote debugging: length of port range"],
5050
host: ['RUBY_DEBUG_HOST', "REMOTE: TCP/IP remote debugging: host", :string, "127.0.0.1"],
51+
proc_regex: ['RUBY_DEBUG_PROC_REGEX', "REMOTE: Regex to match against process name ($0); the port is opened only when it matches"],
5152
sock_path: ['RUBY_DEBUG_SOCK_PATH', "REMOTE: UNIX Domain Socket remote debugging: socket path"],
5253
sock_dir: ['RUBY_DEBUG_SOCK_DIR', "REMOTE: UNIX Domain Socket remote debugging: socket directory"],
5354
local_fs_map: ['RUBY_DEBUG_LOCAL_FS_MAP', "REMOTE: Specify local fs map", :path_map],
@@ -355,6 +356,9 @@ def self.parse_argv argv
355356
o.on('--host=HOST', 'Listening TCP/IP host') do |host|
356357
config[:host] = host
357358
end
359+
o.on('--proc-regex=REGEX', 'Open TCP/IP port only when $0 matches the regex') do |regex|
360+
config[:proc_regex] = regex
361+
end
358362
o.on('--cookie=COOKIE', 'Set a cookie for connection') do |c|
359363
config[:cookie] = c
360364
end

lib/debug/server.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require 'socket'
44
require 'fileutils'
5+
require 'singleton'
56
require_relative 'config'
67
require_relative 'version'
78

@@ -383,11 +384,29 @@ def vscode_setup debug_port
383384
end
384385
end
385386

387+
# Tracks the value of $0 the first time UI_TcpServer#accept runs in a process
388+
# tree. Subsequent calls (e.g. in forked children) use it to detect when the
389+
# process has been renamed via setproctitle, so the proc_regex match is
390+
# evaluated against the post-rename name.
391+
class InitialProcInfo
392+
include Singleton
393+
attr_accessor :info
394+
end
395+
386396
class UI_TcpServer < UI_ServerBase
397+
PROC_WAIT_TIMEOUT = 5
398+
387399
def initialize host: nil, port: nil
388400
@local_addr = nil
389401
@host = host || CONFIG[:host]
390402
@port_save_file = nil
403+
@proc_regex = if (regex = CONFIG[:proc_regex])
404+
begin
405+
Regexp.new(regex)
406+
rescue RegexpError => e
407+
raise ArgumentError, "Invalid RUBY_DEBUG_PROC_REGEX: #{e.message}"
408+
end
409+
end
391410
@port = begin
392411
port_str = (port && port.to_s) || CONFIG[:port] || raise("Specify listening port by RUBY_DEBUG_PORT environment variable.")
393412
case port_str
@@ -426,6 +445,27 @@ def chrome_setup
426445
end
427446

428447
def accept
448+
if @proc_regex
449+
initial_info = InitialProcInfo.instance
450+
if initial_info.info.nil?
451+
initial_info.info = $0
452+
else
453+
# Wait briefly for the process to rename $0 (e.g. setproctitle after fork)
454+
# so the regex match is evaluated against the post-rename name.
455+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PROC_WAIT_TIMEOUT
456+
while $0 == initial_info.info && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
457+
sleep 0.1
458+
end
459+
end
460+
461+
if @proc_regex.match?($0)
462+
DEBUGGER__.warn "Process #{$0.inspect} matches #{@proc_regex.inspect}; opening port"
463+
else
464+
DEBUGGER__.warn "Process #{$0.inspect} does not match #{@proc_regex.inspect}; skipping port"
465+
return
466+
end
467+
end
468+
429469
retry_cnt = 0
430470
super # for fork
431471

misc/README.md.erb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,42 @@ To use TCP/IP, you can set the `RUBY_DEBUG_PORT` environment variable.
372372
$ RUBY_DEBUG_PORT=12345 ruby target.rb
373373
```
374374

375+
#### Filter processes by name with `--proc-regex`
376+
377+
In multi-process applications (e.g. forking job runners) you may want to enable
378+
the debug port only in specific processes — for example, only in workers whose
379+
title was set via `Process.setproctitle` after fork. The `--proc-regex` option
380+
(env var `RUBY_DEBUG_PROC_REGEX`) takes a regular expression that is matched
381+
against `$0` when the TCP server starts in each process. The port is opened
382+
only when it matches; otherwise the listener returns silently and the program
383+
runs without a debugger attached.
384+
385+
```console
386+
$ rdbg --port 3003 --proc-regex '\Asolid-queue-worker' --host 0.0.0.0 -n --open -c './bin/jobs'
387+
```
388+
389+
In a forking parent (the supervisor in the example) the regex will not match,
390+
so no port is opened. After each `fork`, the child re-enters the accept loop;
391+
if its `$0` (set via `Process.setproctitle`) matches, the child opens the port.
392+
393+
To handle the timing window between `fork` and `Process.setproctitle` in the
394+
child, the matcher waits up to 5 seconds for `$0` to change from the value
395+
captured on the first accept call before evaluating the match.
396+
397+
Notes and limitations:
398+
399+
- **Use `--nonstop` (`-n`).** Without it, `RUBY_DEBUG_OPEN` arms an
400+
initial-suspend breakpoint in the parent before any fork. Non-matching
401+
processes would then hit that breakpoint and block waiting for a client
402+
that will never connect. With `--nonstop`, no initial breakpoint is set
403+
and non-matching processes run unaffected.
404+
- The match is on `$0`. It does not look at `RUBY_DEBUG_FORK_MODE` or the
405+
process tree.
406+
- Independent of `--port-range`, which addresses port collisions between
407+
multiple matching processes. The two can be combined.
408+
- An invalid regex raises `ArgumentError` at startup rather than being
409+
deferred until the first connection.
410+
375411
### Integration with external debugger frontend
376412

377413
You can attach with external debugger frontend with VSCode and Chrome.

test/console/proc_regex_test.rb

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# frozen_string_literal: true
2+
3+
require_relative '../support/console_test_case'
4+
require 'debug/session'
5+
require 'debug/server'
6+
7+
module DEBUGGER__
8+
class ProcRegexInitTest < ConsoleTestCase
9+
def teardown
10+
super
11+
CONFIG[:proc_regex] = nil
12+
end
13+
14+
def test_invalid_regex_raises_argument_error
15+
CONFIG[:proc_regex] = '[invalid'
16+
CONFIG[:port] = 0
17+
18+
assert_raise_message(/Invalid RUBY_DEBUG_PROC_REGEX/) do
19+
UI_TcpServer.new
20+
end
21+
end
22+
23+
def test_valid_regex_compiles_at_initialize
24+
CONFIG[:proc_regex] = 'worker.*'
25+
server = UI_TcpServer.new(port: 0)
26+
compiled = server.instance_variable_get(:@proc_regex)
27+
28+
assert_kind_of Regexp, compiled
29+
assert_equal(/worker.*/, compiled)
30+
end
31+
32+
def test_no_proc_regex_leaves_attribute_nil
33+
CONFIG[:proc_regex] = nil
34+
server = UI_TcpServer.new(port: 0)
35+
assert_nil server.instance_variable_get(:@proc_regex)
36+
end
37+
end
38+
39+
class ProcRegexRemoteTest < ConsoleTestCase
40+
def program
41+
<<~RUBY
42+
1| a = 1
43+
2| b = 2
44+
RUBY
45+
end
46+
47+
# When $0 matches the regex, the TCP port is opened normally and the
48+
# debugger logs that the process matched.
49+
def test_port_opens_when_proc_matches
50+
omit "no remote tests" if NO_REMOTE
51+
52+
write_temp_file(strip_line_num(program))
53+
basename = Regexp.escape(File.basename(temp_file_path))
54+
cmd = "#{RDBG_EXECUTABLE} -O --port=0 --proc-regex=#{basename} -- #{temp_file_path}"
55+
56+
remote_info = setup_remote_debuggee(cmd)
57+
assert remote_info.debuggee_backlog.any? { |l| l.include?('matches') && l.include?(File.basename(temp_file_path)) },
58+
"expected match log, got: #{remote_info.debuggee_backlog.inspect}"
59+
assert remote_info.debuggee_backlog.any? { |l| l =~ /Debugger can attach via TCP\/IP/ },
60+
"expected port-open log, got: #{remote_info.debuggee_backlog.inspect}"
61+
ensure
62+
kill_safely(remote_info.pid, force: true) if remote_info
63+
remote_info&.reader_thread&.kill
64+
remote_info&.r&.close
65+
remote_info&.w&.close
66+
end
67+
68+
# When $0 does not match the regex, the listener returns silently without
69+
# opening the port. With --nonstop (no initial-suspend breakpoint), the
70+
# program then runs to completion unaffected by the debugger.
71+
def test_port_skipped_when_proc_does_not_match
72+
omit "no remote tests" if NO_REMOTE
73+
74+
program_with_print = <<~RUBY
75+
puts "PROC_REGEX_TEST_DONE"
76+
RUBY
77+
write_temp_file(program_with_print)
78+
79+
cmd = "#{RDBG_EXECUTABLE} -O --nonstop --port=0 --proc-regex=__NEVER_MATCHES_XYZ__ -- #{temp_file_path}"
80+
backlog = []
81+
r, _w, pid = PTY.spawn(cmd)
82+
83+
Timeout.timeout(TIMEOUT_SEC) do
84+
while line = r.gets
85+
backlog << line
86+
break if line.include?('PROC_REGEX_TEST_DONE')
87+
end
88+
end
89+
90+
assert backlog.any? { |l| l.include?('does not match') && l.include?('skipping port') },
91+
"expected skip log, got: #{backlog.inspect}"
92+
assert backlog.any? { |l| l.include?('PROC_REGEX_TEST_DONE') },
93+
"program should have run to completion, got: #{backlog.inspect}"
94+
refute backlog.any? { |l| l =~ /Debugger can attach via TCP\/IP/ },
95+
"port should not have been opened, got: #{backlog.inspect}"
96+
rescue Errno::EIO
97+
# PTY closed: program already exited
98+
ensure
99+
Process.kill(:TERM, pid) if pid rescue nil
100+
Process.waitpid(pid) if pid rescue nil
101+
r&.close
102+
end
103+
end
104+
end

0 commit comments

Comments
 (0)