Skip to content

Commit 19003ac

Browse files
committed
isolate eval call so it can't muck with local variables
- update rubocop, and fix all rubocop failures - Refactor specs
1 parent 9e0bde4 commit 19003ac

File tree

8 files changed

+145
-72
lines changed

8 files changed

+145
-72
lines changed

.github/workflows/ruby.yml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ permissions:
1818

1919
jobs:
2020
test:
21-
2221
runs-on: ubuntu-latest
2322
strategy:
2423
matrix:
@@ -27,10 +26,7 @@ jobs:
2726
steps:
2827
- uses: actions/checkout@v4
2928
- name: Set up Ruby
30-
# To automatically get bug fixes and new Ruby versions for ruby/setup-ruby,
31-
# change this to (see https://github.com/ruby/setup-ruby#versioning):
32-
# uses: ruby/setup-ruby@v1
33-
uses: ruby/setup-ruby@55283cc23133118229fd3f97f9336ee23a179fcf # v1.146.0
29+
uses: ruby/setup-ruby@v1
3430
with:
3531
ruby-version: ${{ matrix.ruby-version }}
3632
bundler-cache: true # runs 'bundle install' and caches installed gems automatically

.rubocop.yml

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
AllCops:
2-
TargetRubyVersion: 2.4
2+
TargetRubyVersion: 3.1
33
DisplayCopNames: true
4+
NewCops: enable
45

5-
Style/FrozenStringLiteralComment:
6-
Enabled: true
6+
plugins:
7+
- rubocop-rake
8+
- rubocop-rspec
79

10+
Layout/LineLength:
11+
Enabled: false
812
Metrics:
913
Enabled: false
10-
14+
RSpec/DescribedClass:
15+
Enabled: false
16+
RSpec/ExampleLength:
17+
Enabled: false
18+
RSpec/MultipleExpectations:
19+
Enabled: false
1120
Style/Documentation:
1221
Enabled: false
13-
22+
Style/FrozenStringLiteralComment:
23+
Enabled: true
1424
Style/StringLiterals:
1525
EnforcedStyle: double_quotes

Gemfile

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ source "https://rubygems.org"
55
# Specify your gem's dependencies in debug_socket.gemspec
66
gemspec
77

8-
gem "pry"
8+
gem "base64"
9+
gem "ostruct"
10+
gem "pry-byebug"
911
gem "rake"
1012
gem "rspec", "~> 3.8"
11-
gem "rubocop", "0.59.1"
13+
gem "rubocop", "1.73.1"
14+
gem "rubocop-rake"
15+
gem "rubocop-rspec"
16+
gem "ruby-lsp", require: false
17+
gem "syntax_tree", require: false

debug_socket.gemspec

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,7 @@ Gem::Specification.new do |spec|
1717
spec.bindir = "exe"
1818
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
1919
spec.require_paths = ["lib"]
20+
spec.metadata["rubygems_mfa_required"] = "true"
21+
22+
spec.required_ruby_version = ">= 3.1"
2023
end

exe/debug-socket

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ end
1212
socket_path = ARGV[0]
1313
command = ARGV[1] || "backtrace"
1414

15-
warn "\nSending `#{command}` to the following socket: #{socket_path}"\
15+
warn(
16+
"\nSending `#{command}` to the following socket: #{socket_path}" \
1617
"----------------------------------------------------------\n\n"
18+
)
1719

1820
UNIXSocket.open(socket_path) do |socket|
1921
socket.write(command)

lib/debug_socket.rb

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def self.logger
1616
return @logger if defined?(@logger)
1717

1818
require "logger"
19-
@logger = Logger.new(STDERR)
19+
@logger = Logger.new($stderr)
2020
end
2121

2222
def self.start(path, &block)
@@ -32,22 +32,27 @@ def self.start(path, &block)
3232

3333
server = UNIXServer.new(@path)
3434
@thread = Thread.new do
35+
errors = 0
3536
loop do
36-
begin
37-
socket = server.accept
38-
input = socket.read
39-
logger&.warn("debug-socket-command=#{input.inspect}")
40-
41-
self.perform_audit(input, &block) if block
42-
43-
socket.puts(eval(input)) # rubocop:disable Security/Eval
44-
45-
rescue Exception => e # rubocop:disable Lint/RescueException
46-
logger&.error { "debug-socket-error=#{e.inspect} backtrace=#{e.backtrace.inspect}" }
47-
ensure
48-
socket&.close
49-
end
37+
socket = server.accept
38+
input = socket.read
39+
logger&.warn("debug-socket-command=#{input.inspect}")
40+
41+
perform_audit(input, &block) if block
42+
socket.puts(isolated_eval(input))
43+
44+
errors = 0
45+
rescue StandardError => e
46+
logger&.error { "debug-socket-error=#{e.inspect} errors=#{errors} path=#{@path} backtrace=#{e.backtrace.inspect}" }
47+
errors += 1
48+
raise e if errors > 20
49+
50+
sleep(1)
51+
ensure
52+
socket&.close
5053
end
54+
rescue Exception => e # rubocop:disable Lint/RescueException
55+
logger&.error { "debug-socket-error=#{e.inspect} DebugSocket is broken now path=#{@path} backtrace=#{e.backtrace.inspect}" }
5156
end
5257

5358
logger&.unknown { "Debug socket listening on #{@path}" }
@@ -68,8 +73,7 @@ def self.stop
6873
def self.backtrace
6974
pid = Process.pid
7075
"#{Time.now.utc.iso8601} #{$PROGRAM_NAME}\n" + Thread.list.map do |thread|
71-
output =
72-
+"#{Time.now.utc.iso8601} pid=#{pid} thread.object_id=#{thread.object_id} thread.status=#{thread.status}"
76+
output = "#{Time.now.utc.iso8601} pid=#{pid} thread.object_id=#{thread.object_id} thread.status=#{thread.status}"
7377
backtrace = thread.backtrace || []
7478
output << "\n#{backtrace.join("\n")}" if backtrace
7579
output
@@ -79,7 +83,14 @@ def self.backtrace
7983
# Allow debug socket input commands to be audited by an external callback
8084
private_class_method def self.perform_audit(input)
8185
yield input
82-
rescue Exception => e
83-
logger&.error "debug-socket-error=callback unsuccessful: #{e.inspect} for #{input.inspect} socket_path=#{@path}"
86+
rescue Exception => e # rubocop:disable Lint/RescueException
87+
logger&.error "debug-socket-error=callback unsuccessful: #{e.inspect} for #{input.inspect} path=#{@path} backtrace=#{e.backtrace.inspect}"
88+
end
89+
90+
private_class_method def self.isolated_eval(input)
91+
eval(input) # rubocop:disable Security/Eval
92+
rescue Exception => e # rubocop:disable Lint/RescueException
93+
logger&.error { "debug-socket-error=#{e.inspect} input=#{input.inspect} path=#{@path} backtrace=#{e.backtrace.inspect}" }
94+
e.inspect
8495
end
8596
end

spec/debug_socket_spec.rb

Lines changed: 70 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,28 @@
2222
DebugSocket.start(path)
2323
end
2424

25+
def write(str, socket_path = path)
26+
20.times { sleep(0.1) unless File.exist?(socket_path) }
27+
socket = UNIXSocket.new(socket_path)
28+
29+
raise "Socket write timeout=#{socket_path}" unless socket.wait_writable(1)
30+
31+
socket.write(str)
32+
socket.close_write
33+
34+
raise "Socket read timeout=#{socket_path}" unless socket.wait_readable(1)
35+
36+
socket.read
37+
end
38+
2539
after do
2640
DebugSocket.stop
2741
10.times { sleep(1) if File.exist?(path) }
2842
raise "did not cleanup socket file" if File.exist?(path)
2943
end
3044

3145
it "logs and evals input" do
32-
socket.write("2 + 2")
33-
socket.close_write
34-
expect(socket.read).to eq("4\n")
46+
expect(write("2 + 2")).to eq("4\n")
3547
expect(log_buffer.string).to include('debug-socket-command="2 + 2"')
3648
end
3749

@@ -55,38 +67,62 @@
5567
expect { DebugSocket.start(another_path) }
5668
.to raise_exception("debug socket thread already running for this process")
5769

58-
10.times { sleep(1) unless File.exist?(another_path) }
59-
another_socket = UNIXSocket.new(another_path)
60-
another_socket.write("Thread.list.each(&:wakeup)")
61-
another_socket.close_write
62-
expect(another_socket.read).to match(/Thread/)
70+
expect(write("Thread.list.each(&:wakeup)", another_path)).to match(/Thread/)
6371
Process.wait(child, Process::WNOHANG)
6472
else
65-
DebugSocket.start(another_path)
66-
sleep
67-
sleep(1)
68-
DebugSocket.stop
69-
exit!(1)
73+
begin
74+
DebugSocket.start(another_path)
75+
sleep
76+
sleep(1)
77+
DebugSocket.stop
78+
ensure
79+
exit!(1)
80+
end
7081
end
7182
end
7283
end
7384

7485
it "catches errors in the debug socket thread" do
75-
socket.write("asdf}(]")
76-
socket.close_write
77-
expect(socket.read).to eq("")
86+
expect(write("asdf}(]")).to include("SyntaxError")
87+
expect(write("2")).to eq("2\n")
7888

79-
another_socket = UNIXSocket.new(path)
80-
another_socket.write("2")
81-
another_socket.close_write
82-
expect(another_socket.read).to eq("2\n")
83-
84-
expect(log_buffer.string).to include("debug-socket-error=#<SyntaxError: (eval):1: syntax error")
89+
expect(log_buffer.string).to match(/debug-socket-error=#<SyntaxError:.*eval.*syntax error/)
8590
expect(log_buffer.string).to include('debug-socket-command="2"')
8691
end
8792

88-
context 'with proc' do
89-
before do
93+
it "isolates the eval from the local scope" do
94+
expect(write("server = 1")).to eq("1\n")
95+
expect(write("server = 1")).to eq("1\n")
96+
end
97+
98+
it "retries socket errors 10 times then dies" do
99+
20.times { sleep(0.1) unless File.exist?(path) }
100+
101+
slept = false
102+
allow(DebugSocket).to receive(:sleep).and_wrap_original do |original, delay|
103+
next if slept
104+
105+
slept = true
106+
original.call(delay)
107+
end
108+
109+
socket = UNIXSocket.new(path)
110+
socket.write("sleep(1)")
111+
socket.close
112+
113+
20.times do
114+
socket = UNIXSocket.new(path)
115+
socket.close
116+
end
117+
118+
almost_there(250) do
119+
(1..20).each { |i| expect(log_buffer.string).to include("errors=#{i}") }
120+
expect(log_buffer.string).to include("DebugSocket is broken now")
121+
end
122+
end
123+
124+
context "with proc" do
125+
before do
90126
DebugSocket.stop
91127
end
92128

@@ -95,10 +131,7 @@
95131
audit_proc = proc { |input| audit_calls << input }
96132

97133
DebugSocket.start(path, &audit_proc)
98-
99-
socket.write("2 + 2")
100-
socket.close_write
101-
expect(socket.read).to eq("4\n")
134+
expect(write("2 + 2")).to eq("4\n")
102135
expect(audit_calls).to eq(["2 + 2"])
103136
end
104137

@@ -107,9 +140,7 @@
107140

108141
DebugSocket.start(path, &audit_proc)
109142

110-
socket.write("3 + 3")
111-
socket.close_write
112-
expect(socket.read).to eq("6\n")
143+
expect(write("3 + 3")).to eq("6\n")
113144
# No error should be raised to the client, and the command is processed
114145
expect(log_buffer.string).to include('debug-socket-error=callback unsuccessful: #<RuntimeError: audit error> for "3 + 3"')
115146
end
@@ -120,23 +151,23 @@
120151
it "returns a stacktrace for all threads" do
121152
time_pid = "\\d{4}-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ\\ pid=#{Process.pid}"
122153
running_thread = %r{#{time_pid}\ thread\.object_id=#{Thread.current.object_id}\ thread\.status=run\n
123-
.*lib/debug_socket\.rb:\d+:in\ `backtrace'\n
124-
.*lib/debug_socket\.rb:\d+:in\ `block\ in\ backtrace'\n
125-
.*lib/debug_socket\.rb:\d+:in\ `map'\n
126-
.*lib/debug_socket\.rb:\d+:in\ `backtrace'\n
127-
.*spec/debug_socket_spec\.rb:\d+:in\ `block.*'}x
154+
.*lib/debug_socket\.rb:\d+:in\ .*backtrace'\n
155+
.*lib/debug_socket\.rb:\d+:in\ .*block\ in.*backtrace'\n
156+
.*lib/debug_socket\.rb:\d+:in\ .*map'\n
157+
.*lib/debug_socket\.rb:\d+:in\ .*backtrace'\n
158+
.*spec/debug_socket_spec\.rb:\d+:in\ .*block.*'}x
128159
thread = Thread.new { sleep 1 }
129160
sleep 0.1
130161
sleeping_thread = %r{#{time_pid}\ thread\.object_id=#{thread.object_id}\ thread\.status=sleep\n
131-
.*spec/debug_socket_spec\.rb:\d+:in\ `sleep'\n
132-
.*spec/debug_socket_spec\.rb:\d+:in\ `block.*'}x
162+
.*spec/debug_socket_spec\.rb:\d+:in\ .*sleep'\n
163+
.*spec/debug_socket_spec\.rb:\d+:in\ .*block.*'}x
133164
bt = DebugSocket.backtrace
134165
expect(bt).to match(running_thread)
135166
expect(bt).to match(sleeping_thread)
136167
end
137168
end
138169

139-
describe "stress test", slow: true do
170+
describe "stress test", :slow do
140171
it "works with lots of threads, even in jruby" do
141172
threads = Array.new(10) do
142173
Thread.new { 100.times { Thread.new { sleep(0.001) }.join } }

spec/spec_helper.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
11
# frozen_string_literal: true
22

3-
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
3+
require "bundler/setup"
44
require "debug_socket"
5+
require "io/wait"
6+
require "pry-byebug"
7+
8+
RSpec.configure do |_config|
9+
def almost_there(retries = 100)
10+
yield
11+
rescue RSpec::Expectations::ExpectationNotMetError
12+
raise if retries < 1
13+
14+
sleep 0.1
15+
retries -= 1
16+
retry
17+
end
18+
end

0 commit comments

Comments
 (0)