http_loader 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +1 -0
- data/BUGS.md +5 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/PERFORMANCE_REPORT.md +184 -0
- data/README.md +262 -0
- data/REQUIREMENTS.md +41 -0
- data/bin/http_loader +80 -0
- data/certs/http_loader-public_cert.pem +25 -0
- data/http_loader.gemspec +61 -0
- data/lib/http_loader/cli_args.rb +120 -0
- data/lib/http_loader/client/config.rb +33 -0
- data/lib/http_loader/client/error_handler.rb +28 -0
- data/lib/http_loader/client/http_session.rb +118 -0
- data/lib/http_loader/client/logger.rb +111 -0
- data/lib/http_loader/client/slowloris.rb +81 -0
- data/lib/http_loader/client/target_manager.rb +99 -0
- data/lib/http_loader/client.rb +135 -0
- data/lib/http_loader/harness/config.rb +18 -0
- data/lib/http_loader/harness/formatter.rb +82 -0
- data/lib/http_loader/harness/process_manager.rb +81 -0
- data/lib/http_loader/harness/resource_monitor.rb +132 -0
- data/lib/http_loader/harness/telemetry.rb +76 -0
- data/lib/http_loader/harness.rb +110 -0
- data/lib/http_loader/server.rb +117 -0
- data/lib/http_loader/version.rb +6 -0
- data/lib/http_loader.rb +12 -0
- data/lib/rubocop/cop/ai/adverb_spam.rb +46 -0
- data.tar.gz.sig +0 -0
- metadata +379 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# typed: strong
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require 'openssl'
|
|
8
|
+
require 'async'
|
|
9
|
+
require 'async/semaphore'
|
|
10
|
+
require 'time'
|
|
11
|
+
require_relative 'client/config'
|
|
12
|
+
require_relative 'client/logger'
|
|
13
|
+
require_relative 'client/target_manager'
|
|
14
|
+
require_relative 'client/slowloris'
|
|
15
|
+
require_relative 'client/http_session'
|
|
16
|
+
require_relative 'client/error_handler'
|
|
17
|
+
|
|
18
|
+
module HttpLoader
|
|
19
|
+
# Master client class coordinating connection pools through Async Engine.
|
|
20
|
+
class Client
|
|
21
|
+
extend T::Sig
|
|
22
|
+
include ErrorHandler
|
|
23
|
+
|
|
24
|
+
sig { params(config: Config).void }
|
|
25
|
+
def initialize(config)
|
|
26
|
+
@config = config
|
|
27
|
+
@logger = T.let(Logger.new(config.verbose), Logger)
|
|
28
|
+
@target_manager = T.let(TargetManager.new(config), TargetManager)
|
|
29
|
+
@slow_sess = T.let(Slowloris.new(config, @logger), Slowloris)
|
|
30
|
+
@http_sess = T.let(HttpSession.new(config, @logger), HttpSession)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
sig { void }
|
|
34
|
+
def start
|
|
35
|
+
log_startup_message
|
|
36
|
+
trap('INT') { exit(0) }
|
|
37
|
+
@logger.setup_files!
|
|
38
|
+
|
|
39
|
+
Async { |task| run_engine(task) }
|
|
40
|
+
ensure
|
|
41
|
+
@logger.flush_synchronously!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
sig { params(task: T.untyped).void }
|
|
47
|
+
def run_engine(task)
|
|
48
|
+
logger_t = @logger.run_task(task)
|
|
49
|
+
sem = Async::Semaphore.new(@config.max_concurrent_connections, parent: task)
|
|
50
|
+
|
|
51
|
+
conn_tasks = Array.new(@config.connections) do |i|
|
|
52
|
+
perform_sleep(calc_ramp, task: task) if calc_ramp.positive?
|
|
53
|
+
sem.async { exec_conn(i) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
conn_tasks.each(&:wait)
|
|
57
|
+
logger_t.stop
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
sig { void }
|
|
61
|
+
def log_startup_message
|
|
62
|
+
puts "[Client] Starting #{@config.connections} #{@target_manager.protocol_label} connections to targeted urls..."
|
|
63
|
+
puts '[Client] Note: Output of individual pings is suppressed.' unless @config.verbose
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sig { returns(Float) }
|
|
67
|
+
def calc_ramp
|
|
68
|
+
if @config.ramp_up.positive?
|
|
69
|
+
@config.ramp_up.to_f / @config.connections
|
|
70
|
+
elsif @config.connections_per_second.positive?
|
|
71
|
+
1.0 / @config.connections_per_second
|
|
72
|
+
else
|
|
73
|
+
0.0
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
sig { params(dur: Float, task: T.untyped).void }
|
|
78
|
+
def perform_sleep(dur, task: nil)
|
|
79
|
+
task ? task.sleep(dur) : sleep(dur)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sig { params(base: Float).returns(Float) }
|
|
83
|
+
def calc_sleep(base)
|
|
84
|
+
return base if @config.jitter.zero?
|
|
85
|
+
|
|
86
|
+
v = base * @config.jitter
|
|
87
|
+
[0.0, base + rand(-v..v)].max
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
sig { params(idx: Integer).void }
|
|
91
|
+
def exec_conn(idx)
|
|
92
|
+
loop do
|
|
93
|
+
run_session(idx, Time.now)
|
|
94
|
+
break unless @config.reopen_closed_connections
|
|
95
|
+
|
|
96
|
+
sleep(calc_sleep(@config.reopen_interval))
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
sig { params(idx: Integer, start_t: Time).void }
|
|
101
|
+
def run_session(idx, start_t)
|
|
102
|
+
ctx = @target_manager.context_for(idx)
|
|
103
|
+
uri = T.cast(ctx[:uri], URI::Generic)
|
|
104
|
+
|
|
105
|
+
start_http(uri, fetch_opts(idx, ctx)) do |http|
|
|
106
|
+
http.max_retries = 0 if http.respond_to?(:max_retries=)
|
|
107
|
+
@logger.info("[Client #{idx}] Connection established to #{uri.host}.")
|
|
108
|
+
dispatch_sess(idx, uri, http, start_t)
|
|
109
|
+
end
|
|
110
|
+
@logger.info("[Client #{idx}] Connection gracefully closed.")
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
handle_err(idx, e)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
sig { params(idx: Integer, ctx: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
|
116
|
+
def fetch_opts(idx, ctx)
|
|
117
|
+
args = T.cast(ctx[:http_args], T::Hash[Symbol, T.untyped])
|
|
118
|
+
@target_manager.http_opts_for(idx, args)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
sig { params(uri: URI::Generic, opts: T::Hash[Symbol, T.untyped], block: T.proc.params(arg: Net::HTTP).void).void }
|
|
122
|
+
def start_http(uri, opts, &block)
|
|
123
|
+
Net::HTTP.start(T.must(uri.host), uri.port, **opts, &block)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
sig { params(idx: Integer, uri: URI::Generic, http: Net::HTTP, start_t: Time).void }
|
|
127
|
+
def dispatch_sess(idx, uri, http, start_t)
|
|
128
|
+
if @config.slowloris_delay.positive?
|
|
129
|
+
@slow_sess.run(idx, uri, http, start_t)
|
|
130
|
+
else
|
|
131
|
+
@http_sess.run(idx, uri, http, start_t)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# typed: strong
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module HttpLoader
|
|
7
|
+
class Harness
|
|
8
|
+
# Config parameters for Harness manager execution natively.
|
|
9
|
+
class Config < T::Struct
|
|
10
|
+
const :connections, Integer
|
|
11
|
+
const :target_urls, T::Array[String], default: []
|
|
12
|
+
const :use_https, T::Boolean, default: false
|
|
13
|
+
const :client_args, T::Array[String], default: []
|
|
14
|
+
const :export_json, T.nilable(String), default: nil
|
|
15
|
+
const :target_duration, Float, default: 0.0
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# typed: strong
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module HttpLoader
|
|
7
|
+
class Harness
|
|
8
|
+
# Formatter handles printing load test statistics dynamically and robustly.
|
|
9
|
+
module Formatter
|
|
10
|
+
extend T::Sig
|
|
11
|
+
extend T::Helpers
|
|
12
|
+
|
|
13
|
+
requires_ancestor { HttpLoader::Harness }
|
|
14
|
+
|
|
15
|
+
sig { void }
|
|
16
|
+
def print_startup_banner
|
|
17
|
+
msg = if @config.target_urls.size > 1
|
|
18
|
+
"**MULTIPLE TARGETS** (#{@config.target_urls.size} URLs)."
|
|
19
|
+
elsif @config.target_urls.size == 1
|
|
20
|
+
"**EXTERNAL URL** #{@config.target_urls.first}."
|
|
21
|
+
elsif @config.use_https
|
|
22
|
+
'**HTTPS**.'
|
|
23
|
+
else
|
|
24
|
+
'**HTTP**.'
|
|
25
|
+
end
|
|
26
|
+
puts "[Harness] Starting test with #{@config.connections} connections to #{msg}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { void }
|
|
30
|
+
def print_table_header
|
|
31
|
+
puts '[Harness] Monitoring resources (Press Ctrl+C to stop)...'
|
|
32
|
+
puts '-' * 125
|
|
33
|
+
puts 'Time (UTC) | Real Conns | Srv CPU/Thrds | Srv Mem | Srv Mem/Conn | ' \
|
|
34
|
+
'Cli CPU/Thrds | Cli Mem | Cli Mem/Conn '
|
|
35
|
+
puts '-' * 125
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sig { params(params: T::Hash[Symbol, T.untyped]).void }
|
|
39
|
+
def log_table_row(params)
|
|
40
|
+
puts format('%<t>-10s | %<ac>-11s | %<sc>-16s | %<sm>-14s | %<sk>-14s | %<cc>-16s | %<cm>-14s | %<ck>-14s',
|
|
41
|
+
t: params[:t], ac: params[:ac], sc: params[:sc], sm: params[:sm],
|
|
42
|
+
sk: params[:sk], cc: params[:cc], cm: params[:cm], ck: params[:ck])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { params(kilo: Float, connections: Integer, pid: T.nilable(Integer)).returns(String) }
|
|
46
|
+
def format_kb_conn(kilo, connections, pid)
|
|
47
|
+
return 'EXTERNAL' if pid.nil?
|
|
48
|
+
return 'N/A' if connections.zero? || connections.negative?
|
|
49
|
+
|
|
50
|
+
"#{(kilo / connections).round(2)} KB"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { params(active: Integer, c_cpu: String, c_th: Integer, c_m: String).void }
|
|
54
|
+
def print_combined_stats(active, c_cpu, c_th, c_m)
|
|
55
|
+
s_cpu, s_mem, s_th, s_conn = extract_server_stats
|
|
56
|
+
_cc, _cm, c_kb, _ct = @monitor.process_stats(@pm.client_pid)
|
|
57
|
+
c_conn = format_kb_conn(c_kb, active, @pm.client_pid)
|
|
58
|
+
|
|
59
|
+
log_table_row(
|
|
60
|
+
t: Time.now.utc.strftime('%H:%M:%S'), ac: active.to_s,
|
|
61
|
+
sc: "#{s_cpu}% / #{s_th}", sm: s_mem, sk: s_conn,
|
|
62
|
+
cc: "#{c_cpu}% / #{c_th}", cm: c_m, ck: c_conn
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sig { returns([String, String, Integer, String]) }
|
|
67
|
+
def extract_server_stats
|
|
68
|
+
s_cpu, s_mem, s_kb, s_th = @monitor.process_stats(@pm.server_pid)
|
|
69
|
+
active_s = @monitor.count_established_connections(@pm.server_pid)
|
|
70
|
+
s_conn = format_kb_conn(s_kb, active_s, @pm.server_pid)
|
|
71
|
+
[s_cpu, s_mem, s_th, s_conn]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
sig { returns([Integer, String, Integer, String]) }
|
|
75
|
+
def extract_client_stats
|
|
76
|
+
c_cpu, c_mem, _c_kb, c_th = @monitor.process_stats(@pm.client_pid)
|
|
77
|
+
active_c = @monitor.count_established_connections(@pm.client_pid)
|
|
78
|
+
[active_c, c_cpu, c_th, c_mem]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# typed: strong
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module HttpLoader
|
|
7
|
+
class Harness
|
|
8
|
+
# Manages child process lifecycles natively and checks status dynamically.
|
|
9
|
+
class ProcessManager
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { returns(T.nilable(Integer)) }
|
|
13
|
+
attr_reader :server_pid, :client_pid
|
|
14
|
+
|
|
15
|
+
sig { params(config: HttpLoader::Harness::Config).void }
|
|
16
|
+
def initialize(config)
|
|
17
|
+
@config = config
|
|
18
|
+
@server_pid = T.let(nil, T.nilable(Integer))
|
|
19
|
+
@client_pid = T.let(nil, T.nilable(Integer))
|
|
20
|
+
@log_dir = T.let(File.expand_path('../../../logs', __dir__), String)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { void }
|
|
24
|
+
def spawn_processes
|
|
25
|
+
FileUtils.mkdir_p(@log_dir)
|
|
26
|
+
spawn_server unless @config.target_urls.any?
|
|
27
|
+
spawn_client
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sig { void }
|
|
31
|
+
def cleanup
|
|
32
|
+
begin
|
|
33
|
+
Process.kill('INT', T.must(@server_pid)) if @server_pid
|
|
34
|
+
rescue StandardError; nil
|
|
35
|
+
end
|
|
36
|
+
begin
|
|
37
|
+
Process.kill('INT', T.must(@client_pid)) if @client_pid
|
|
38
|
+
rescue StandardError; nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { returns(T::Boolean) }
|
|
43
|
+
def missing_process?
|
|
44
|
+
return true if @client_pid && dead?(@client_pid)
|
|
45
|
+
return true if @server_pid && dead?(@server_pid)
|
|
46
|
+
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
sig { void }
|
|
53
|
+
def spawn_server
|
|
54
|
+
server_cmd = ['ruby', 'bin/http_loader', 'server']
|
|
55
|
+
server_cmd << '--https' if @config.use_https
|
|
56
|
+
@server_pid = Process.spawn(*server_cmd, out: File.join(@log_dir, 'server.log'),
|
|
57
|
+
err: File.join(@log_dir, 'server.err'))
|
|
58
|
+
puts "[Harness] Started server with PID #{@server_pid}"
|
|
59
|
+
sleep(2)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
sig { void }
|
|
63
|
+
def spawn_client
|
|
64
|
+
client_cmd = ['ruby', 'bin/http_loader', 'client']
|
|
65
|
+
client_cmd += @config.client_args.empty? ? ["--connections_count=#{@config.connections}"] : @config.client_args
|
|
66
|
+
|
|
67
|
+
@client_pid = Process.spawn(*client_cmd, out: File.join(@log_dir, 'client.log'),
|
|
68
|
+
err: File.join(@log_dir, 'client.err'))
|
|
69
|
+
puts "[Harness] Started client with PID #{@client_pid}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
sig { params(pid: Integer).returns(T::Boolean) }
|
|
73
|
+
def dead?(pid)
|
|
74
|
+
Process.getpgid(pid)
|
|
75
|
+
false
|
|
76
|
+
rescue Errno::ESRCH
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# typed: strong
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
require 'open3'
|
|
6
|
+
|
|
7
|
+
module HttpLoader
|
|
8
|
+
class Harness
|
|
9
|
+
# Monitors CPU, memory, and established connections for running processes.
|
|
10
|
+
class ResourceMonitor
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { void }
|
|
14
|
+
def initialize
|
|
15
|
+
@cpu_prev = T.let({}, T::Hash[Integer, T::Hash[Symbol, T.untyped]])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
sig { params(pid: T.nilable(Integer)).returns([String, String, Float, Integer]) }
|
|
19
|
+
def process_stats(pid)
|
|
20
|
+
return ['EXTERNAL', 'EXTERNAL', 0.0, 0] if pid.nil?
|
|
21
|
+
|
|
22
|
+
if File.exist?("/proc/#{pid}/stat") && File.exist?("/proc/#{pid}/statm")
|
|
23
|
+
native_linux_stats(pid)
|
|
24
|
+
else
|
|
25
|
+
fallback_ps_stats(pid)
|
|
26
|
+
end
|
|
27
|
+
rescue StandardError
|
|
28
|
+
fallback_ps_stats(pid)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { params(pid: T.nilable(Integer)).returns(Integer) }
|
|
32
|
+
def count_established_connections(pid)
|
|
33
|
+
return 0 if pid.nil?
|
|
34
|
+
|
|
35
|
+
if File.directory?("/proc/#{pid}/fd")
|
|
36
|
+
count_linux_connections(pid)
|
|
37
|
+
else
|
|
38
|
+
count_lsof_connections(pid)
|
|
39
|
+
end
|
|
40
|
+
rescue StandardError
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
sig { params(pid: Integer).returns([String, String, Float, Integer]) }
|
|
47
|
+
def native_linux_stats(pid)
|
|
48
|
+
rss_kb = read_rss_kb(pid)
|
|
49
|
+
cpu_perc = read_cpu_perc(pid)
|
|
50
|
+
[cpu_perc.to_s, "#{(rss_kb / 1024.0).round(2)} MB", rss_kb, read_threads(pid)]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { params(pid: Integer).returns(Float) }
|
|
54
|
+
def read_rss_kb(pid)
|
|
55
|
+
rss_pages = T.must(File.read("/proc/#{pid}/statm").split[1]).to_i
|
|
56
|
+
(rss_pages * fetch_page_size) / 1024.0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sig { params(pid: Integer).returns(Float) }
|
|
60
|
+
def read_cpu_perc(pid)
|
|
61
|
+
stat_array = File.read("/proc/#{pid}/stat").split
|
|
62
|
+
calculate_cpu(pid, T.must(stat_array[13]).to_f + T.must(stat_array[14]).to_f)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { params(pid: Integer).returns(Integer) }
|
|
66
|
+
def read_threads(pid)
|
|
67
|
+
File.read("/proc/#{pid}/status").match(/Threads:\s+(\d+)/)&.[](1)&.to_i || 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sig { returns(Integer) }
|
|
71
|
+
def fetch_page_size
|
|
72
|
+
return 4096 unless File.exist?('/usr/bin/getconf')
|
|
73
|
+
|
|
74
|
+
out, _s = Open3.capture2('getconf PAGE_SIZE')
|
|
75
|
+
out.to_i.positive? ? out.to_i : 4096
|
|
76
|
+
rescue StandardError
|
|
77
|
+
4096
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
sig { params(pid: Integer, total_ticks: Float).returns(Float) }
|
|
81
|
+
def calculate_cpu(pid, total_ticks)
|
|
82
|
+
prev = @cpu_prev[pid]
|
|
83
|
+
now = Time.now.utc
|
|
84
|
+
cpu_perc = 0.0
|
|
85
|
+
|
|
86
|
+
if prev && (time_diff = now - prev[:time]).positive?
|
|
87
|
+
cpu_perc = (((total_ticks - prev[:ticks]) / 100.0) / time_diff * 100).round(1)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
@cpu_prev[pid] = { ticks: total_ticks, time: now }
|
|
91
|
+
cpu_perc
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
sig { params(pid: Integer).returns([String, String, Float, Integer]) }
|
|
95
|
+
def fallback_ps_stats(pid)
|
|
96
|
+
out, _s = Open3.capture2('ps', '-o', '%cpu,rss', '-p', pid.to_s)
|
|
97
|
+
lines = out.strip.split("\n")
|
|
98
|
+
return ['N/A', 'N/A', 0.0, 0] if lines.size < 2
|
|
99
|
+
|
|
100
|
+
cpu, rss_kb_str = T.must(lines[1]).strip.split(/\s+/)
|
|
101
|
+
rss_kb_val = T.must(rss_kb_str).to_f
|
|
102
|
+
|
|
103
|
+
[T.must(cpu), "#{(rss_kb_val / 1024.0).round(2)} MB", rss_kb_val, fallback_threads_count(pid)]
|
|
104
|
+
rescue StandardError
|
|
105
|
+
['N/A', 'N/A', 0.0, 0]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
sig { params(pid: Integer).returns(Integer) }
|
|
109
|
+
def fallback_threads_count(pid)
|
|
110
|
+
out, _s = Open3.capture2("ps -M -p #{pid}")
|
|
111
|
+
[out.strip.split("\n").size - 1, 0].max
|
|
112
|
+
rescue StandardError
|
|
113
|
+
1
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
sig { params(pid: Integer).returns(Integer) }
|
|
117
|
+
def count_linux_connections(pid)
|
|
118
|
+
Dir.glob("/proc/#{pid}/fd/*").count do |fd_path|
|
|
119
|
+
File.readlink(fd_path).start_with?('socket:[')
|
|
120
|
+
rescue StandardError
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
sig { params(pid: Integer).returns(Integer) }
|
|
126
|
+
def count_lsof_connections(pid)
|
|
127
|
+
out, _s = Open3.capture2("lsof -p #{pid} -n -P")
|
|
128
|
+
out.scan('ESTABLISHED').count
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# typed: strong
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module HttpLoader
|
|
8
|
+
class Harness
|
|
9
|
+
# Exports telemetry and logs bottlenecks asynchronously and synchronously at termination.
|
|
10
|
+
class Telemetry
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { params(log_dir: String, export_json: T.nilable(String)).void }
|
|
14
|
+
def initialize(log_dir, export_json)
|
|
15
|
+
@log_dir = log_dir
|
|
16
|
+
@export_json = export_json
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
sig { params(peak_connections: Integer, start_time: Time).void }
|
|
20
|
+
def export!(peak_connections, start_time)
|
|
21
|
+
return unless @export_json
|
|
22
|
+
|
|
23
|
+
payload = generate_payload(peak_connections, start_time, count_bottlenecks(read_logs))
|
|
24
|
+
File.write(@export_json, JSON.generate(payload))
|
|
25
|
+
puts "[Harness] Telemetry JSON securely sinked to #{@export_json}."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { void }
|
|
29
|
+
def check_bottlenecks!
|
|
30
|
+
errors = build_bottleneck_messages(count_bottlenecks(read_logs))
|
|
31
|
+
puts format(' => BOTTLENECK ACTIVE: %s', errors.join(' | ')) if errors.any?
|
|
32
|
+
rescue StandardError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
sig { params(peak: Integer, start: Time, counts: T::Hash[Symbol, Integer]).returns(T::Hash[Symbol, T.untyped]) }
|
|
39
|
+
def generate_payload(peak, start, counts)
|
|
40
|
+
{
|
|
41
|
+
peak_connections: peak, test_duration_seconds: (Time.now.utc - start).round(2),
|
|
42
|
+
errors: { emfile: counts[:emfile], eaddrnotavail: counts[:eaddr_count], thread_limit: counts[:thread_errors] }
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { returns(String) }
|
|
47
|
+
def read_logs
|
|
48
|
+
begin
|
|
49
|
+
File.read(File.join(@log_dir, 'client.log'))
|
|
50
|
+
rescue StandardError; ''
|
|
51
|
+
end + begin
|
|
52
|
+
File.read(File.join(@log_dir, 'client.err'))
|
|
53
|
+
rescue StandardError; ''
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
sig { params(log_text: String).returns(T::Hash[Symbol, Integer]) }
|
|
58
|
+
def count_bottlenecks(log_text)
|
|
59
|
+
{
|
|
60
|
+
emfile: log_text.scan('ERROR_EMFILE').size,
|
|
61
|
+
eaddr_count: log_text.scan('ERROR_EADDRNOTAVAIL').size,
|
|
62
|
+
thread_errors: log_text.scan('ERROR_THREADLIMIT').size
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sig { params(counts: T::Hash[Symbol, Integer]).returns(T::Array[String]) }
|
|
67
|
+
def build_bottleneck_messages(counts)
|
|
68
|
+
errors = []
|
|
69
|
+
errors << "[OS FDs Limit: #{counts[:emfile]} EMFILE]" if counts[:emfile].positive?
|
|
70
|
+
errors << "[OS Ports Limit: #{counts[:eaddr_count]} EADDRNOTAVAIL]" if counts[:eaddr_count].positive?
|
|
71
|
+
errors << "[OS Thread Limit: #{counts[:thread_errors]} ThreadError]" if counts[:thread_errors].positive?
|
|
72
|
+
errors
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# typed: strong
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require_relative 'harness/config'
|
|
7
|
+
require_relative 'harness/resource_monitor'
|
|
8
|
+
require_relative 'harness/telemetry'
|
|
9
|
+
require_relative 'harness/process_manager'
|
|
10
|
+
require_relative 'harness/formatter'
|
|
11
|
+
|
|
12
|
+
module HttpLoader
|
|
13
|
+
# Harness orchestrates the entire load testing lifecycle seamlessly and securely.
|
|
14
|
+
class Harness
|
|
15
|
+
extend T::Sig
|
|
16
|
+
include Formatter
|
|
17
|
+
|
|
18
|
+
sig { params(config: Config).void }
|
|
19
|
+
def initialize(config)
|
|
20
|
+
@config = config
|
|
21
|
+
@start_time = T.let(Time.now.utc, Time)
|
|
22
|
+
@peak_connections = T.let(0, Integer)
|
|
23
|
+
log_dir = T.let(File.expand_path('../../logs', __dir__), String)
|
|
24
|
+
@telemetry = T.let(Telemetry.new(log_dir, config.export_json), Telemetry)
|
|
25
|
+
@pm = T.let(ProcessManager.new(config), ProcessManager)
|
|
26
|
+
@monitor = T.let(ResourceMonitor.new, ResourceMonitor)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { void }
|
|
30
|
+
def start
|
|
31
|
+
$stdout.sync = true
|
|
32
|
+
print_startup_banner
|
|
33
|
+
bump_file_limits
|
|
34
|
+
|
|
35
|
+
run_lifecycle
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
sig { void }
|
|
41
|
+
def run_lifecycle
|
|
42
|
+
@pm.spawn_processes
|
|
43
|
+
trap('INT') do
|
|
44
|
+
puts "\n[Harness] Caught interrupt, cleaning up processes..."
|
|
45
|
+
@pm.cleanup
|
|
46
|
+
exit(0)
|
|
47
|
+
end
|
|
48
|
+
monitor_resources
|
|
49
|
+
ensure
|
|
50
|
+
@telemetry.export!(@peak_connections, @start_time)
|
|
51
|
+
@pm.cleanup
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
sig { void }
|
|
55
|
+
def monitor_resources
|
|
56
|
+
print_table_header
|
|
57
|
+
@start_time = Time.now.utc
|
|
58
|
+
|
|
59
|
+
loop do
|
|
60
|
+
break if duration_exceeded?
|
|
61
|
+
|
|
62
|
+
tick_result = tick_failed?
|
|
63
|
+
break if tick_result
|
|
64
|
+
|
|
65
|
+
@telemetry.check_bottlenecks!
|
|
66
|
+
sleep(2)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sig { returns(T::Boolean) }
|
|
71
|
+
def duration_exceeded?
|
|
72
|
+
elapsed = Time.now.utc - @start_time
|
|
73
|
+
return false unless @config.target_duration.positive? && elapsed >= @config.target_duration
|
|
74
|
+
|
|
75
|
+
puts "[Harness] Target duration mathematically reached (#{@config.target_duration}s). auto-shutdown."
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
sig { returns(T::Boolean) }
|
|
80
|
+
def tick_failed?
|
|
81
|
+
active_c, c_cpu, c_th, c_m = extract_client_stats
|
|
82
|
+
@peak_connections = [@peak_connections, active_c].max
|
|
83
|
+
|
|
84
|
+
return true if missing_socket?(active_c, c_cpu, c_th, c_m)
|
|
85
|
+
return true if @pm.missing_process?
|
|
86
|
+
|
|
87
|
+
print_combined_stats(active_c, c_cpu, c_th, c_m)
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
sig { void }
|
|
92
|
+
def bump_file_limits
|
|
93
|
+
Process.setrlimit(Process::RLIMIT_NOFILE, @config.connections + 1024)
|
|
94
|
+
rescue Errno::EPERM
|
|
95
|
+
puts '[Harness] Warning: Could not set RLIMIT_NOFILE automatically.'
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
sig { params(active: Integer, c_cpu: String, c_th: Integer, c_m: String).returns(T::Boolean) }
|
|
99
|
+
def missing_socket?(active, c_cpu, c_th, c_m)
|
|
100
|
+
return false unless @config.target_urls.any? && @peak_connections.positive? && active.zero?
|
|
101
|
+
|
|
102
|
+
log_table_row(
|
|
103
|
+
t: Time.now.utc.strftime('%H:%M:%S'), ac: active, sc: 'EXTERNAL', sm: 'N/A',
|
|
104
|
+
sk: 'N/A', cc: "#{c_cpu}% / #{c_th}T", cm: c_m, ck: 'N/A'
|
|
105
|
+
)
|
|
106
|
+
puts "\n[Harness] \u26A0\uFE0F EXTERNAL SERVER DISCONNECTED!"
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|