From 41465fb6e3db395ccac62e4804e66481d9f93380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Crystian=20Lea=CC=83o?= Date: Wed, 29 Apr 2026 11:19:42 -0300 Subject: [PATCH] Add open_proctitle option to filter which processes open the TCP debug port Introduces RUBY_DEBUG_OPEN_PROCTITLE / --open-proctitle=PROCTITLE. When set, UI_TcpServer#accept compares the value against $0 in each process. 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). The value can be either form: - "foo" => exact-match string (compared with ==) - "/foo/i" => regexp (parsed from /pattern/[flags]) 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 --open-proctitle='/\Asolid-queue-worker/', only processes whose $0 matches the regexp 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 regexp form 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 parsing of both forms, initialize-time validation, the match path (regexp and string), and the no-match path with --nonstop (port not opened, program runs through). --- README.md | 47 ++++++++ lib/debug/config.rb | 4 + lib/debug/server.rb | 67 ++++++++++++ misc/README.md.erb | 45 ++++++++ test/console/open_proctitle_test.rb | 163 ++++++++++++++++++++++++++++ 5 files changed, 326 insertions(+) create mode 100644 test/console/open_proctitle_test.rb diff --git a/README.md b/README.md index 4d9dc631a..cade8bdd5 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,51 @@ To use TCP/IP, you can set the `RUBY_DEBUG_PORT` environment variable. $ RUBY_DEBUG_PORT=12345 ruby target.rb ``` +#### Filter processes by `$0` with `--open-proctitle` + +In multi-process applications (e.g. forking job runners) you may want to enable +the debug port only in specific processes — for example, only in workers whose +title was set via `Process.setproctitle` after fork. The `--open-proctitle` +option (env var `RUBY_DEBUG_OPEN_PROCTITLE`) is matched against `$0` when the +TCP server starts in each process. The port is opened only when it matches; +otherwise the listener returns silently and the program runs without a +debugger attached. + +The value can be either a literal string for an exact-match comparison, or a +regexp wrapped in `/.../` (with optional flags) for pattern matching: + +```console +# Exact match: open only when $0 == "my-worker" +$ rdbg --port 3003 --open-proctitle 'my-worker' --host 0.0.0.0 -n --open -c './bin/start' + +# Regexp: open only when $0 =~ /\Asolid-queue-worker/ +$ rdbg --port 3003 --open-proctitle '/\Asolid-queue-worker/' --host 0.0.0.0 -n --open -c './bin/jobs' +``` + +In a forking parent (the supervisor in the example) the value will not match, +so no port is opened. After each `fork`, the child re-enters the accept loop; +if its `$0` (set via `Process.setproctitle`) matches, the child opens the port. + +To handle the timing window between `fork` and `Process.setproctitle` in the +child, the matcher waits up to 5 seconds for `$0` to change from the value +captured on the first accept call before evaluating the match. + +Notes and limitations: + +- **Use `--nonstop` (`-n`).** Without it, `RUBY_DEBUG_OPEN` arms an + initial-suspend breakpoint in the parent before any fork. Non-matching + processes would then hit that breakpoint and block waiting for a client + that will never connect. With `--nonstop`, no initial breakpoint is set + and non-matching processes run unaffected. +- The match is on `$0`. It does not look at `RUBY_DEBUG_FORK_MODE` or the + process tree. +- Independent of `--port-range`, which addresses port collisions between + multiple matching processes. The two can be combined. +- A value of the form `/.../[flags]` is interpreted as a regexp. Anything + else (including paths like `/usr/bin/foo`) is an exact-match string. +- An invalid regexp raises `ArgumentError` at startup rather than being + deferred until the first connection. + ### Integration with external debugger frontend You can attach with external debugger frontend with VSCode and Chrome. @@ -520,6 +565,7 @@ config set no_color true * `RUBY_DEBUG_PORT` (`port`): TCP/IP remote debugging: port * `RUBY_DEBUG_PORT_RANGE` (`port_range`): TCP/IP remote debugging: length of port range * `RUBY_DEBUG_HOST` (`host`): TCP/IP remote debugging: host (default: 127.0.0.1) + * `RUBY_DEBUG_OPEN_PROCTITLE` (`open_proctitle`): Open the port only when $0 matches this value (string for exact match; /pattern/[flags] for regexp) * `RUBY_DEBUG_SOCK_PATH` (`sock_path`): UNIX Domain Socket remote debugging: socket path * `RUBY_DEBUG_SOCK_DIR` (`sock_dir`): UNIX Domain Socket remote debugging: socket directory * `RUBY_DEBUG_LOCAL_FS_MAP` (`local_fs_map`): Specify local fs map @@ -938,6 +984,7 @@ Debug console mode: --port=PORT Listening TCP/IP port --port-range=PORT_RANGE Number of ports to try to connect to --host=HOST Listening TCP/IP host + --open-proctitle=PROCTITLE Open TCP/IP port only when $0 matches PROCTITLE (string for exact match; /pattern/[flags] for regexp) --cookie=COOKIE Set a cookie for connection --session-name=NAME Session name diff --git a/lib/debug/config.rb b/lib/debug/config.rb index a5af775af..a72ad1d67 100644 --- a/lib/debug/config.rb +++ b/lib/debug/config.rb @@ -48,6 +48,7 @@ module DEBUGGER__ port: ['RUBY_DEBUG_PORT', "REMOTE: TCP/IP remote debugging: port"], port_range: ['RUBY_DEBUG_PORT_RANGE', "REMOTE: TCP/IP remote debugging: length of port range"], host: ['RUBY_DEBUG_HOST', "REMOTE: TCP/IP remote debugging: host", :string, "127.0.0.1"], + open_proctitle: ['RUBY_DEBUG_OPEN_PROCTITLE', "REMOTE: Open the port only when $0 matches this value (string for exact match; /pattern/[flags] for regexp)"], sock_path: ['RUBY_DEBUG_SOCK_PATH', "REMOTE: UNIX Domain Socket remote debugging: socket path"], sock_dir: ['RUBY_DEBUG_SOCK_DIR', "REMOTE: UNIX Domain Socket remote debugging: socket directory"], local_fs_map: ['RUBY_DEBUG_LOCAL_FS_MAP', "REMOTE: Specify local fs map", :path_map], @@ -355,6 +356,9 @@ def self.parse_argv argv o.on('--host=HOST', 'Listening TCP/IP host') do |host| config[:host] = host end + o.on('--open-proctitle=PROCTITLE', 'Open TCP/IP port only when $0 matches PROCTITLE (string for exact match; /pattern/[flags] for regexp)') do |proctitle| + config[:open_proctitle] = proctitle + end o.on('--cookie=COOKIE', 'Set a cookie for connection') do |c| config[:cookie] = c end diff --git a/lib/debug/server.rb b/lib/debug/server.rb index 0a3c7d266..2feb52990 100644 --- a/lib/debug/server.rb +++ b/lib/debug/server.rb @@ -2,6 +2,7 @@ require 'socket' require 'fileutils' +require 'singleton' require_relative 'config' require_relative 'version' @@ -383,11 +384,49 @@ def vscode_setup debug_port end end + # Tracks the value of $0 the first time UI_TcpServer#accept runs in this + # process. After fork, the child has its own (inherited) instance and the + # original value is used to detect when the child has been renamed via + # Process.setproctitle, so the open_proctitle match is evaluated against the + # post-rename name. + class InitialProcInfo + include Singleton + attr_accessor :info + end + class UI_TcpServer < UI_ServerBase + PROCTITLE_WAIT_TIMEOUT = 5 + + # Parse the open_proctitle config value: + # + # "/pattern/flags" => Regexp.new(pattern, flags) + # "anything else" => the string itself (compared with ==) + # + # Raises ArgumentError on an invalid regexp. + def self.parse_open_proctitle(value) + return nil if value.nil? + + if (m = value.match(/\A\/(.*)\/([imxnesu]*)\z/m)) + pattern, flags = m[1], m[2] + regexp_options = 0 + regexp_options |= Regexp::IGNORECASE if flags.include?('i') + regexp_options |= Regexp::MULTILINE if flags.include?('m') + regexp_options |= Regexp::EXTENDED if flags.include?('x') + begin + Regexp.new(pattern, regexp_options) + rescue RegexpError => e + raise ArgumentError, "Invalid RUBY_DEBUG_OPEN_PROCTITLE regexp: #{e.message}" + end + else + value + end + end + def initialize host: nil, port: nil @local_addr = nil @host = host || CONFIG[:host] @port_save_file = nil + @open_proctitle = self.class.parse_open_proctitle(CONFIG[:open_proctitle]) @port = begin port_str = (port && port.to_s) || CONFIG[:port] || raise("Specify listening port by RUBY_DEBUG_PORT environment variable.") case port_str @@ -412,6 +451,13 @@ def initialize host: nil, port: nil super() end + private def open_proctitle_match?(proctitle) + case @open_proctitle + when Regexp then @open_proctitle.match?(proctitle) + else @open_proctitle == proctitle + end + end + def chrome_setup require_relative 'server_cdp' @@ -426,6 +472,27 @@ def chrome_setup end def accept + if @open_proctitle + initial_info = InitialProcInfo.instance + if initial_info.info.nil? + initial_info.info = $0 + else + # Wait briefly for the process to rename $0 (e.g. setproctitle after + # fork) so the match is evaluated against the post-rename name. + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PROCTITLE_WAIT_TIMEOUT + while $0 == initial_info.info && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline + sleep 0.1 + end + end + + if open_proctitle_match?($0) + DEBUGGER__.warn "Process #{$0.inspect} matches #{@open_proctitle.inspect}; opening port" + else + DEBUGGER__.warn "Process #{$0.inspect} does not match #{@open_proctitle.inspect}; skipping port" + return + end + end + retry_cnt = 0 super # for fork diff --git a/misc/README.md.erb b/misc/README.md.erb index 9565a74da..de689ec3f 100644 --- a/misc/README.md.erb +++ b/misc/README.md.erb @@ -372,6 +372,51 @@ To use TCP/IP, you can set the `RUBY_DEBUG_PORT` environment variable. $ RUBY_DEBUG_PORT=12345 ruby target.rb ``` +#### Filter processes by `$0` with `--open-proctitle` + +In multi-process applications (e.g. forking job runners) you may want to enable +the debug port only in specific processes — for example, only in workers whose +title was set via `Process.setproctitle` after fork. The `--open-proctitle` +option (env var `RUBY_DEBUG_OPEN_PROCTITLE`) is matched against `$0` when the +TCP server starts in each process. The port is opened only when it matches; +otherwise the listener returns silently and the program runs without a +debugger attached. + +The value can be either a literal string for an exact-match comparison, or a +regexp wrapped in `/.../` (with optional flags) for pattern matching: + +```console +# Exact match: open only when $0 == "my-worker" +$ rdbg --port 3003 --open-proctitle 'my-worker' --host 0.0.0.0 -n --open -c './bin/start' + +# Regexp: open only when $0 =~ /\Asolid-queue-worker/ +$ rdbg --port 3003 --open-proctitle '/\Asolid-queue-worker/' --host 0.0.0.0 -n --open -c './bin/jobs' +``` + +In a forking parent (the supervisor in the example) the value will not match, +so no port is opened. After each `fork`, the child re-enters the accept loop; +if its `$0` (set via `Process.setproctitle`) matches, the child opens the port. + +To handle the timing window between `fork` and `Process.setproctitle` in the +child, the matcher waits up to 5 seconds for `$0` to change from the value +captured on the first accept call before evaluating the match. + +Notes and limitations: + +- **Use `--nonstop` (`-n`).** Without it, `RUBY_DEBUG_OPEN` arms an + initial-suspend breakpoint in the parent before any fork. Non-matching + processes would then hit that breakpoint and block waiting for a client + that will never connect. With `--nonstop`, no initial breakpoint is set + and non-matching processes run unaffected. +- The match is on `$0`. It does not look at `RUBY_DEBUG_FORK_MODE` or the + process tree. +- Independent of `--port-range`, which addresses port collisions between + multiple matching processes. The two can be combined. +- A value of the form `/.../[flags]` is interpreted as a regexp. Anything + else (including paths like `/usr/bin/foo`) is an exact-match string. +- An invalid regexp raises `ArgumentError` at startup rather than being + deferred until the first connection. + ### Integration with external debugger frontend You can attach with external debugger frontend with VSCode and Chrome. diff --git a/test/console/open_proctitle_test.rb b/test/console/open_proctitle_test.rb new file mode 100644 index 000000000..14159d9bc --- /dev/null +++ b/test/console/open_proctitle_test.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require_relative '../support/console_test_case' +require 'debug/session' +require 'debug/server' + +module DEBUGGER__ + class OpenProctitleParseTest < ConsoleTestCase + def test_parses_string_as_exact_match + assert_equal 'worker', UI_TcpServer.parse_open_proctitle('worker') + end + + def test_parses_slash_delimited_value_as_regexp + result = UI_TcpServer.parse_open_proctitle('/worker.*/') + assert_kind_of Regexp, result + assert_equal(/worker.*/, result) + end + + def test_parses_regexp_flags + result = UI_TcpServer.parse_open_proctitle('/worker/i') + assert_kind_of Regexp, result + assert_equal Regexp::IGNORECASE, result.options & Regexp::IGNORECASE + end + + def test_invalid_regexp_raises_argument_error + assert_raise_message(/Invalid RUBY_DEBUG_OPEN_PROCTITLE regexp/) do + UI_TcpServer.parse_open_proctitle('/[invalid/') + end + end + + def test_nil_returns_nil + assert_nil UI_TcpServer.parse_open_proctitle(nil) + end + + def test_path_starting_with_slash_is_exact_string + # Only "/.../[flags]" is regexp; arbitrary strings starting with `/` + # but not ending with `/` are exact-match strings. + assert_equal '/usr/bin/foo', UI_TcpServer.parse_open_proctitle('/usr/bin/foo') + end + end + + class OpenProctitleInitTest < ConsoleTestCase + def teardown + super + CONFIG[:open_proctitle] = nil + end + + def test_invalid_regexp_raises_at_initialize + CONFIG[:open_proctitle] = '/[invalid/' + + assert_raise_message(/Invalid RUBY_DEBUG_OPEN_PROCTITLE/) do + UI_TcpServer.new(port: 0) + end + end + + def test_regexp_value_is_compiled_to_regexp + CONFIG[:open_proctitle] = '/worker.*/' + server = UI_TcpServer.new(port: 0) + compiled = server.instance_variable_get(:@open_proctitle) + + assert_kind_of Regexp, compiled + assert_equal(/worker.*/, compiled) + end + + def test_string_value_is_kept_as_string + CONFIG[:open_proctitle] = 'my-worker' + server = UI_TcpServer.new(port: 0) + kept = server.instance_variable_get(:@open_proctitle) + + assert_kind_of String, kept + assert_equal 'my-worker', kept + end + + def test_no_value_leaves_attribute_nil + CONFIG[:open_proctitle] = nil + server = UI_TcpServer.new(port: 0) + assert_nil server.instance_variable_get(:@open_proctitle) + end + end + + class OpenProctitleRemoteTest < ConsoleTestCase + def program + <<~RUBY + 1| a = 1 + 2| b = 2 + RUBY + end + + # When $0 matches the regexp form, the TCP port is opened normally and + # the debugger logs the match. + def test_port_opens_when_regexp_matches + omit "no remote tests" if NO_REMOTE + + write_temp_file(strip_line_num(program)) + basename = Regexp.escape(File.basename(temp_file_path)) + cmd = "#{RDBG_EXECUTABLE} -O --port=0 --open-proctitle=/#{basename}/ -- #{temp_file_path}" + + remote_info = setup_remote_debuggee(cmd) + assert remote_info.debuggee_backlog.any? { |l| l.include?('matches') && l.include?(File.basename(temp_file_path)) }, + "expected match log, got: #{remote_info.debuggee_backlog.inspect}" + assert remote_info.debuggee_backlog.any? { |l| l =~ /Debugger can attach via TCP\/IP/ }, + "expected port-open log, got: #{remote_info.debuggee_backlog.inspect}" + ensure + kill_safely(remote_info.pid, force: true) if remote_info + remote_info&.reader_thread&.kill + remote_info&.r&.close + remote_info&.w&.close + end + + # When $0 equals the exact-match string form, the port is also opened. + def test_port_opens_when_string_equals_proctitle + omit "no remote tests" if NO_REMOTE + + write_temp_file(strip_line_num(program)) + cmd = "#{RDBG_EXECUTABLE} -O --port=0 --open-proctitle=#{temp_file_path} -- #{temp_file_path}" + + remote_info = setup_remote_debuggee(cmd) + assert remote_info.debuggee_backlog.any? { |l| l =~ /Debugger can attach via TCP\/IP/ }, + "expected port-open log, got: #{remote_info.debuggee_backlog.inspect}" + ensure + kill_safely(remote_info.pid, force: true) if remote_info + remote_info&.reader_thread&.kill + remote_info&.r&.close + remote_info&.w&.close + end + + # When $0 does not match the value, the listener returns silently without + # opening the port. With --nonstop (no initial-suspend breakpoint), the + # program then runs to completion unaffected by the debugger. + def test_port_skipped_when_value_does_not_match + omit "no remote tests" if NO_REMOTE + + program_with_print = <<~RUBY + puts "OPEN_PROCTITLE_TEST_DONE" + RUBY + write_temp_file(program_with_print) + + cmd = "#{RDBG_EXECUTABLE} -O --nonstop --port=0 --open-proctitle=__NEVER_MATCHES_XYZ__ -- #{temp_file_path}" + backlog = [] + r, _w, pid = PTY.spawn(cmd) + + Timeout.timeout(TIMEOUT_SEC) do + while line = r.gets + backlog << line + break if line.include?('OPEN_PROCTITLE_TEST_DONE') + end + end + + assert backlog.any? { |l| l.include?('does not match') && l.include?('skipping port') }, + "expected skip log, got: #{backlog.inspect}" + assert backlog.any? { |l| l.include?('OPEN_PROCTITLE_TEST_DONE') }, + "program should have run to completion, got: #{backlog.inspect}" + refute backlog.any? { |l| l =~ /Debugger can attach via TCP\/IP/ }, + "port should not have been opened, got: #{backlog.inspect}" + rescue Errno::EIO + # PTY closed: program already exited + ensure + Process.kill(:TERM, pid) if pid rescue nil + Process.waitpid(pid) if pid rescue nil + r&.close + end + end +end