Skip to content

Commit 41465fb

Browse files
author
Crystian Leão
committed
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).
1 parent 95997c2 commit 41465fb

5 files changed

Lines changed: 326 additions & 0 deletions

File tree

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,51 @@ 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 `$0` with `--open-proctitle`
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 `--open-proctitle`
380+
option (env var `RUBY_DEBUG_OPEN_PROCTITLE`) is matched against `$0` when the
381+
TCP server starts in each process. The port is opened only when it matches;
382+
otherwise the listener returns silently and the program runs without a
383+
debugger attached.
384+
385+
The value can be either a literal string for an exact-match comparison, or a
386+
regexp wrapped in `/.../` (with optional flags) for pattern matching:
387+
388+
```console
389+
# Exact match: open only when $0 == "my-worker"
390+
$ rdbg --port 3003 --open-proctitle 'my-worker' --host 0.0.0.0 -n --open -c './bin/start'
391+
392+
# Regexp: open only when $0 =~ /\Asolid-queue-worker/
393+
$ rdbg --port 3003 --open-proctitle '/\Asolid-queue-worker/' --host 0.0.0.0 -n --open -c './bin/jobs'
394+
```
395+
396+
In a forking parent (the supervisor in the example) the value will not match,
397+
so no port is opened. After each `fork`, the child re-enters the accept loop;
398+
if its `$0` (set via `Process.setproctitle`) matches, the child opens the port.
399+
400+
To handle the timing window between `fork` and `Process.setproctitle` in the
401+
child, the matcher waits up to 5 seconds for `$0` to change from the value
402+
captured on the first accept call before evaluating the match.
403+
404+
Notes and limitations:
405+
406+
- **Use `--nonstop` (`-n`).** Without it, `RUBY_DEBUG_OPEN` arms an
407+
initial-suspend breakpoint in the parent before any fork. Non-matching
408+
processes would then hit that breakpoint and block waiting for a client
409+
that will never connect. With `--nonstop`, no initial breakpoint is set
410+
and non-matching processes run unaffected.
411+
- The match is on `$0`. It does not look at `RUBY_DEBUG_FORK_MODE` or the
412+
process tree.
413+
- Independent of `--port-range`, which addresses port collisions between
414+
multiple matching processes. The two can be combined.
415+
- A value of the form `/.../[flags]` is interpreted as a regexp. Anything
416+
else (including paths like `/usr/bin/foo`) is an exact-match string.
417+
- An invalid regexp raises `ArgumentError` at startup rather than being
418+
deferred until the first connection.
419+
375420
### Integration with external debugger frontend
376421

377422
You can attach with external debugger frontend with VSCode and Chrome.
@@ -520,6 +565,7 @@ config set no_color true
520565
* `RUBY_DEBUG_PORT` (`port`): TCP/IP remote debugging: port
521566
* `RUBY_DEBUG_PORT_RANGE` (`port_range`): TCP/IP remote debugging: length of port range
522567
* `RUBY_DEBUG_HOST` (`host`): TCP/IP remote debugging: host (default: 127.0.0.1)
568+
* `RUBY_DEBUG_OPEN_PROCTITLE` (`open_proctitle`): Open the port only when $0 matches this value (string for exact match; /pattern/[flags] for regexp)
523569
* `RUBY_DEBUG_SOCK_PATH` (`sock_path`): UNIX Domain Socket remote debugging: socket path
524570
* `RUBY_DEBUG_SOCK_DIR` (`sock_dir`): UNIX Domain Socket remote debugging: socket directory
525571
* `RUBY_DEBUG_LOCAL_FS_MAP` (`local_fs_map`): Specify local fs map
@@ -938,6 +984,7 @@ Debug console mode:
938984
--port=PORT Listening TCP/IP port
939985
--port-range=PORT_RANGE Number of ports to try to connect to
940986
--host=HOST Listening TCP/IP host
987+
--open-proctitle=PROCTITLE Open TCP/IP port only when $0 matches PROCTITLE (string for exact match; /pattern/[flags] for regexp)
941988
--cookie=COOKIE Set a cookie for connection
942989
--session-name=NAME Session name
943990

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+
open_proctitle: ['RUBY_DEBUG_OPEN_PROCTITLE', "REMOTE: Open the port only when $0 matches this value (string for exact match; /pattern/[flags] for regexp)"],
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('--open-proctitle=PROCTITLE', 'Open TCP/IP port only when $0 matches PROCTITLE (string for exact match; /pattern/[flags] for regexp)') do |proctitle|
360+
config[:open_proctitle] = proctitle
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: 67 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,49 @@ def vscode_setup debug_port
383384
end
384385
end
385386

387+
# Tracks the value of $0 the first time UI_TcpServer#accept runs in this
388+
# process. After fork, the child has its own (inherited) instance and the
389+
# original value is used to detect when the child has been renamed via
390+
# Process.setproctitle, so the open_proctitle match is evaluated against the
391+
# post-rename name.
392+
class InitialProcInfo
393+
include Singleton
394+
attr_accessor :info
395+
end
396+
386397
class UI_TcpServer < UI_ServerBase
398+
PROCTITLE_WAIT_TIMEOUT = 5
399+
400+
# Parse the open_proctitle config value:
401+
#
402+
# "/pattern/flags" => Regexp.new(pattern, flags)
403+
# "anything else" => the string itself (compared with ==)
404+
#
405+
# Raises ArgumentError on an invalid regexp.
406+
def self.parse_open_proctitle(value)
407+
return nil if value.nil?
408+
409+
if (m = value.match(/\A\/(.*)\/([imxnesu]*)\z/m))
410+
pattern, flags = m[1], m[2]
411+
regexp_options = 0
412+
regexp_options |= Regexp::IGNORECASE if flags.include?('i')
413+
regexp_options |= Regexp::MULTILINE if flags.include?('m')
414+
regexp_options |= Regexp::EXTENDED if flags.include?('x')
415+
begin
416+
Regexp.new(pattern, regexp_options)
417+
rescue RegexpError => e
418+
raise ArgumentError, "Invalid RUBY_DEBUG_OPEN_PROCTITLE regexp: #{e.message}"
419+
end
420+
else
421+
value
422+
end
423+
end
424+
387425
def initialize host: nil, port: nil
388426
@local_addr = nil
389427
@host = host || CONFIG[:host]
390428
@port_save_file = nil
429+
@open_proctitle = self.class.parse_open_proctitle(CONFIG[:open_proctitle])
391430
@port = begin
392431
port_str = (port && port.to_s) || CONFIG[:port] || raise("Specify listening port by RUBY_DEBUG_PORT environment variable.")
393432
case port_str
@@ -412,6 +451,13 @@ def initialize host: nil, port: nil
412451
super()
413452
end
414453

454+
private def open_proctitle_match?(proctitle)
455+
case @open_proctitle
456+
when Regexp then @open_proctitle.match?(proctitle)
457+
else @open_proctitle == proctitle
458+
end
459+
end
460+
415461
def chrome_setup
416462
require_relative 'server_cdp'
417463

@@ -426,6 +472,27 @@ def chrome_setup
426472
end
427473

428474
def accept
475+
if @open_proctitle
476+
initial_info = InitialProcInfo.instance
477+
if initial_info.info.nil?
478+
initial_info.info = $0
479+
else
480+
# Wait briefly for the process to rename $0 (e.g. setproctitle after
481+
# fork) so the match is evaluated against the post-rename name.
482+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PROCTITLE_WAIT_TIMEOUT
483+
while $0 == initial_info.info && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
484+
sleep 0.1
485+
end
486+
end
487+
488+
if open_proctitle_match?($0)
489+
DEBUGGER__.warn "Process #{$0.inspect} matches #{@open_proctitle.inspect}; opening port"
490+
else
491+
DEBUGGER__.warn "Process #{$0.inspect} does not match #{@open_proctitle.inspect}; skipping port"
492+
return
493+
end
494+
end
495+
429496
retry_cnt = 0
430497
super # for fork
431498

misc/README.md.erb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,51 @@ 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 `$0` with `--open-proctitle`
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 `--open-proctitle`
380+
option (env var `RUBY_DEBUG_OPEN_PROCTITLE`) is matched against `$0` when the
381+
TCP server starts in each process. The port is opened only when it matches;
382+
otherwise the listener returns silently and the program runs without a
383+
debugger attached.
384+
385+
The value can be either a literal string for an exact-match comparison, or a
386+
regexp wrapped in `/.../` (with optional flags) for pattern matching:
387+
388+
```console
389+
# Exact match: open only when $0 == "my-worker"
390+
$ rdbg --port 3003 --open-proctitle 'my-worker' --host 0.0.0.0 -n --open -c './bin/start'
391+
392+
# Regexp: open only when $0 =~ /\Asolid-queue-worker/
393+
$ rdbg --port 3003 --open-proctitle '/\Asolid-queue-worker/' --host 0.0.0.0 -n --open -c './bin/jobs'
394+
```
395+
396+
In a forking parent (the supervisor in the example) the value will not match,
397+
so no port is opened. After each `fork`, the child re-enters the accept loop;
398+
if its `$0` (set via `Process.setproctitle`) matches, the child opens the port.
399+
400+
To handle the timing window between `fork` and `Process.setproctitle` in the
401+
child, the matcher waits up to 5 seconds for `$0` to change from the value
402+
captured on the first accept call before evaluating the match.
403+
404+
Notes and limitations:
405+
406+
- **Use `--nonstop` (`-n`).** Without it, `RUBY_DEBUG_OPEN` arms an
407+
initial-suspend breakpoint in the parent before any fork. Non-matching
408+
processes would then hit that breakpoint and block waiting for a client
409+
that will never connect. With `--nonstop`, no initial breakpoint is set
410+
and non-matching processes run unaffected.
411+
- The match is on `$0`. It does not look at `RUBY_DEBUG_FORK_MODE` or the
412+
process tree.
413+
- Independent of `--port-range`, which addresses port collisions between
414+
multiple matching processes. The two can be combined.
415+
- A value of the form `/.../[flags]` is interpreted as a regexp. Anything
416+
else (including paths like `/usr/bin/foo`) is an exact-match string.
417+
- An invalid regexp raises `ArgumentError` at startup rather than being
418+
deferred until the first connection.
419+
375420
### Integration with external debugger frontend
376421

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

0 commit comments

Comments
 (0)