#!/usr/bin/env perl # SPDX-FileCopyrightText: 2026 GPForum Contributors # SPDX-License-Identifier: BSD-3-Clause package main; use v5.40; use FindBin qw($Bin); use lib "$Bin/../lib"; # The event loop GPForum requires (kqueue or epoll) is chosen before anything # loads EV, for the load generator's own loop. use GPForum::OS::Reactor; use Carp qw(croak); use Const::Fast; use DBI; use EV; use English qw(-no_match_vars); use Errno qw(EAGAIN EINPROGRESS EINTR); use Fcntl qw(F_GETFL F_SETFL O_NONBLOCK); use File::Spec; use File::Temp qw(tempdir tempfile); use Getopt::Long qw(GetOptionsFromArray); use JSON::MaybeXS qw(decode_json encode_json); use List::Util qw(sum0); use Mojo::UserAgent; use Mojo::Util qw(url_escape); use POSIX qw(WNOHANG _exit setsid); use Socket qw( AF_INET AF_UNIX IPPROTO_TCP SOCK_STREAM TCP_NODELAY inet_aton pack_sockaddr_in pack_sockaddr_un ); use Time::HiRes qw(clock_gettime CLOCK_MONOTONIC sleep); use GPForum::Benchmark::Measure qw(percentile); use GPForum::Benchmark::Process qw(free_port); our $VERSION = '0.001'; # What the hot pages cost through the listener the service runs, under # concurrent clients. Each side is a checkout: its own database, migrated # and seeded with the medium profile by its own bin/gpforum, and its own # server started as the service starts it -- `bin/gpforum start --service # --foreground`, production settings, the workers asked for -- listening on # a Unix socket or on TCP loopback. Members register through /register, # each from its own forwarded address, are activated in the database and # sign in through /login, so their sessions are real server-side ones. # # Each measurement is one page at one concurrency: that many keep-alive # connections, as a proxy's upstream connections are, each sending its next # request when the last is answered, with the headers the shipped nginx site # adds. The sides alternate, their order reversed every round, and the # report is each cell's median over the rounds. No proxy runs: the clients # speak to the listener directly, so the time is the server's. # # --connections counts the connections each worker holds in the middle of a # cell; --burst N opens N more a quarter of the way into it, all at once, # and reports what they waited on their own, as a proxy's new upstream # connections wait when the load rises. const my %DEFAULT => ( duration => 8, warmup => 2, rounds => 3, workers => 4, members => 400, procs => 2, ); const my @CONCURRENCY => ( 1, 8, 32, 128 ); const my $MILLISECONDS => 1_000; const my $PERCENT => 100; const my $P50 => 50; const my $P95 => 95; const my $P99 => 99; const my $USAGE_ERROR => 2; const my $READ_LIMIT => 100_000_000; const my $READ_MEMBERS => 8; const my $BUFFER => 262_144; const my $READY => 60; const my $POLL => 0.2; const my $START_DELAY => 0.2; const my $RECONNECT => 0.01; const my $GRACE => 30; const my $STOP_WAIT => 15; const my $WRITE_WARMUP => 0.5; const my $BURST_SHARE => 0.75; const my $SECRET_BYTES => 32; const my $BYTE => 256; const my $OCTET => 256; const my $HTTP_ERROR => 400; const my $HTTP_OK => 200; const my $CRLF2 => "\r\n\r\n"; const my %ANSWERED => map { $_ => 1 } qw(200 302 303); const my $HOST => 'forum.bench.test'; const my $PASSWORD => 'socket bench correct horse staple'; const my $THREAD_ID => '018f1004-0001-7000-8000-000000000001'; const my $CATEGORY_ID => '018f1001-0001-7000-8000-000000000001'; const my @REPLY_THREAD => ( 101 .. 116 ); const my $REPLY_BODY => 'body_source=A+socket+bench+reply+{n}%2C+with+a' . '+sentence+or+two+of+ordinary+text+in+it.'; const my %HEADERS => ( 'Accept' => 'text/html', 'Accept-Encoding' => 'gzip, deflate, br', 'X-Forwarded-Proto' => 'https', ); const my %PAGE => ( thread_member => { path => "/t/$THREAD_ID", who => 'member' }, thread_guest => { path => "/t/$THREAD_ID", who => 'guest' }, home => { path => q{/}, who => 'member' }, category => { path => "/c/$CATEGORY_ID", who => 'member' }, search => { path => '/search?q=performance', who => 'member' }, # The form a browser without the page's script posts: answered with a # redirect to the new post. reply => { method => 'POST', who => 'writer', body => "csrf_token={csrf}&command_id={uuid}&$REPLY_BODY", }, # What the page's script posts: the composer's draft fields as well, # answered with the new post and a fresh composer. reply_htmx => { method => 'POST', who => 'writer', headers => { 'HX-Request' => 'true' }, body => 'csrf_token={csrf}&command_id={uuid}&draft_id={uuid}' . "&draft_revision=0&$REPLY_BODY", }, ); const my @PAGES => qw( thread_member thread_guest home category search reply reply_htmx ); exit main(@ARGV); sub main (@arguments) { my $option = _options(@arguments); return _usage( $option->{usage} ) if defined $option->{usage}; my $admin_dsn = $ENV{GPFORUM_DATABASE_DSN} // croak 'set GPFORUM_DATABASE_DSN to a server the user may CREATE in'; my @sides = map { _side( $_, $admin_dsn, $option ) } @{ $option->{side} }; my ( $rows, $error ); try { for my $side (@sides) { _prepare($side); } $rows = _measure( \@sides, $option ); } catch ($caught) { $error = $caught; }; _finish( \@sides, $option ); croak $error if defined $error; print _report( \@sides, $rows, $option ) or croak 'cannot write the report'; return 0; } sub _options (@arguments) { my %option = ( %DEFAULT, side => [], page => [], concurrency => [] ); if ( !GetOptionsFromArray( \@arguments, \%option, 'side=s@', 'page=s@', 'concurrency=i@', 'duration=f', 'warmup=f', 'rounds=i', 'workers=i', 'members=i', 'procs=i', 'json=s', 'connections', 'keep', 'help', 'burst=i', ) ) { return { usage => $USAGE_ERROR }; } return { usage => 0 } if $option{help}; if ( !@{ $option{page} } ) { @{ $option{page} } = @PAGES; } if ( !@{ $option{concurrency} } ) { @{ $option{concurrency} } = @CONCURRENCY; } my @unknown = grep { !$PAGE{$_} } @{ $option{page} }; if ( @arguments || @unknown || !@{ $option{side} } || $option{rounds} < 1 || $option{members} < $READ_MEMBERS ) { return { usage => $USAGE_ERROR }; } return \%option; } sub _usage ($status) { my $pages = join q{ }, @PAGES; print <<"USAGE" or croak 'cannot write the usage'; Usage: script/bench-socket --side LABEL=CHECKOUT[,tcp][,poll]... [--page NAME]... [--concurrency N]... [--duration S] [--warmup S] [--rounds N] [--workers N] [--members N] [--procs N] [--connections] [--burst N] [--json FILE] [--keep] Pages: $pages A side listens on a Unix socket and reaches PostgreSQL through its socket; tcp makes both TCP loopback (a checkout from before 2026-10-16), poll runs Mojo::Reactor::Poll (one from before EV was required). GPFORUM_DATABASE_DSN names a server, by its socket directory, the user may CREATE DATABASE on; for a tcp side it must listen on 127.0.0.1 too. USAGE return $status; } # A side as --side gives it: a label, a checkout, and how it is reached. sub _side ( $spelling, $admin_dsn, $option ) { my ( $label, $rest ) = split /=/msx, $spelling, 2; croak "--side $spelling: LABEL=CHECKOUT[,tcp][,poll]" if !defined $rest || !length $label; my ( $checkout, @flags ) = split /,/msx, $rest; croak "--side $spelling: $checkout has no bin/gpforum" if !-x "$checkout/bin/gpforum"; my %flag = map { $_ => 1 } @flags; my $name = "gpforum_bench_socket_${label}_$PROCESS_ID"; my $dsn = _side_dsn( $admin_dsn, $name, $flag{tcp} ); my $run = tempdir( 'gpforum-bench-socket-XXXXXX', TMPDIR => 1 ); return { label => $label, checkout => File::Spec->rel2abs($checkout), tcp => $flag{tcp} ? 1 : 0, poll => $flag{poll} ? 1 : 0, database => $name, dsn => $dsn, admin => $admin_dsn, run => $run, workers => $option->{workers}, members => $option->{members}, target => $flag{tcp} ? 'tcp:127.0.0.1:' . free_port() : "unix:$run/gpforum.sock", }; } # The side's database on the same server: through the socket the admin DSN # names, or for a tcp side through 127.0.0.1 on the same port. sub _side_dsn ( $admin_dsn, $name, $tcp ) { ( my $dsn = $admin_dsn ) =~ s/dbname=[^;]+/dbname=$name/msx; if ($tcp) { $dsn =~ s/host=[^;]+/host=127.0.0.1/msx; } return $dsn; } sub _connect ($dsn) { return DBI->connect( $dsn, $ENV{GPFORUM_DATABASE_USER}, $ENV{GPFORUM_DATABASE_PASSWORD}, { AutoCommit => 1, PrintError => 0, RaiseError => 1 }, ); } # The database, migrated and seeded by the side's own commands; the server; # its members. sub _prepare ($side) { my $admin = _connect( $side->{admin} ); $admin->do( 'CREATE DATABASE ' . $admin->quote_identifier( $side->{database} ) ); $side->{created} = 1; $admin->disconnect; my %database = ( GPFORUM_DATABASE_DSN => $side->{dsn}, LC_ALL => 'en_US.UTF-8', ); for my $command ( [ 'migrate', '--quiet' ], [ 'performance-seed', '--profile', 'medium' ] ) { _run_quietly( $side, \%database, @{$command} ); } _start($side); $side->{members_signed_in} = _sign_in($side); return; } sub _run_quietly ( $side, $environment, @command ) { my $pid = fork // croak "cannot fork: $OS_ERROR"; if ( !$pid ) { local %ENV = ( %ENV, %{$environment} ); chdir $side->{checkout} or _exit(1); open STDOUT, '>>', "$side->{run}/setup.log" or _exit(1); open STDERR, '>&', \*STDOUT or _exit(1); exec {"$side->{checkout}/bin/gpforum"} "$side->{checkout}/bin/gpforum", @command or _exit(1); } waitpid $pid, 0; croak "$side->{label}: gpforum @command failed; see $side->{run}/setup.log" if $CHILD_ERROR; return; } # The service as its unit starts it, with production settings a laptop can # give: mail through sendmail (the outbox worker that would send it does not # run), no antivirus (nothing is uploaded), the read limit raised. sub _service_environment ($side) { my %environment = ( LC_ALL => 'en_US.UTF-8', GPFORUM_ENV => 'production', GPFORUM_WARMUP_ENABLED => 1, GPFORUM_PUBLIC_BASE_URL => "https://$HOST", GPFORUM_SESSION_SECRET => _random_hex(), GPFORUM_METRICS_TOKEN => _random_hex(), GPFORUM_DATABASE_DSN => $side->{dsn}, GPFORUM_MAIL_TRANSPORT => 'sendmail', GPFORUM_MAIL_FROM => "forum\@$HOST", GPFORUM_ANTIVIRUS => 'none', GPFORUM_LOG_PATH => "$side->{run}/gpforum.log", GPFORUM_WEB_PROCESSES => $side->{workers}, GPFORUM_RUNTIME_WORKER_POLICY => 'configured', GPFORUM_RUNTIME_PID_FILE => "$side->{run}/hypnotoad.pid", GPFORUM_RUNTIME_LISTEN => _listen($side), GPFORUM_FORUM_READ_RATE_LIMIT => $READ_LIMIT, ); if ( $side->{poll} ) { $environment{MOJO_REACTOR} = 'Mojo::Reactor::Poll'; } return \%environment; } sub _listen ($side) { my ( $kind, $where ) = split /:/msx, $side->{target}, 2; return "http://$where" if $kind eq 'tcp'; return 'http+unix://' . url_escape($where); } sub _random_hex { return join q{}, map { sprintf '%02x', int rand $BYTE } 1 .. $SECRET_BYTES; } sub _start ($side) { my $environment = _service_environment($side); my $pid = fork // croak "cannot fork: $OS_ERROR"; if ( !$pid ) { setsid(); local %ENV = ( %ENV, %{$environment} ); if ( !$side->{poll} ) { delete $ENV{MOJO_REACTOR}; } chdir $side->{checkout} or _exit(1); open STDOUT, '>', "$side->{run}/service.log" or _exit(1); open STDERR, '>&', \*STDOUT or _exit(1); exec {"$side->{checkout}/bin/gpforum"} "$side->{checkout}/bin/gpforum", qw(start --service --foreground) or _exit(1); } $side->{pid} = $pid; _await_ready($side); return; } sub _await_ready ($side) { my $deadline = clock_gettime(CLOCK_MONOTONIC) + $READY; while ( clock_gettime(CLOCK_MONOTONIC) < $deadline ) { croak "$side->{label}: the service stopped; see $side->{run}" if waitpid( $side->{pid}, WNOHANG ) == $side->{pid}; my $tx = _agent()->get( _base($side) . '/health/live' => _proxy() ); return if ( $tx->res->code // 0 ) == $HTTP_OK; sleep $POLL; } croak "$side->{label}: the service did not answer in $READY s"; } sub _agent { state $ua = do { my $agent = Mojo::UserAgent->new( max_redirects => 0, request_timeout => $GRACE, ); $agent->cookie_jar->ignore( sub { return 1; } ); $agent; }; return $ua; } sub _base ($side) { my ( $kind, $where ) = split /:/msx, $side->{target}, 2; return "http://$where" if $kind eq 'tcp'; return 'http+unix://' . url_escape($where); } sub _proxy ( $address = '10.9.0.1' ) { return { %HEADERS, Host => $HOST, 'X-Forwarded-For' => $address, 'X-Real-IP' => $address, }; } # The members: registered, activated in the database, # signed in, each with the session's CSRF token read from a page. sub _sign_in ($side) { my $dbh = _connect( $side->{dsn} ); my @members; for my $number ( 1 .. $side->{members} ) { push @members, _member( $side, $dbh, $number ); } $dbh->disconnect; return \@members; } sub _member ( $side, $dbh, $number ) { my %member = ( name => "socket_bench_$number", address => sprintf( '10.%d.%d.%d', 1 + int( $number / ( $OCTET * $OCTET ) ), int( $number / $OCTET ) % $OCTET, $number % $OCTET ), jar => {}, ); my $form = _visit( $side, \%member, GET => '/register' ); _visit( $side, \%member, POST => '/register', { command_id => _field( $form, 'command_id' ), csrf_token => _field( $form, 'csrf_token' ), username => $member{name}, display_name => "Socket Bench $number", email => "$member{name}\@example.test", password => $PASSWORD, } ); $dbh->do( q{UPDATE users SET status = 'active', email_verified_at = now()} . ' WHERE username = ?', undef, $member{name} ); my $login = _visit( $side, \%member, GET => '/login' ); _visit( $side, \%member, POST => '/login', { command_id => _field( $login, 'command_id' ), csrf_token => _field( $login, 'csrf_token' ), identifier => $member{name}, password => $PASSWORD, } ); my $home = _visit( $side, \%member, GET => q{/} ); croak "$side->{label}: $member{name} could not sign in" if !$home->res->dom->at('form[action="/logout"]'); return { address => $member{address}, csrf => _field( $home, 'csrf_token' ), cookie => join q{; }, map { "$_=$member{jar}{$_}" } sort keys %{ $member{jar} }, }; } sub _visit ( $side, $member, $method, $path, $form = undef ) { my %headers = %{ _proxy( $member->{address} ) }; if ( %{ $member->{jar} } ) { $headers{Cookie} = join q{; }, map { "$_=$member->{jar}{$_}" } sort keys %{ $member->{jar} }; } my $url = _base($side) . $path; my $tx = $form ? _agent()->build_tx( $method => $url => \%headers => form => $form ) : _agent()->build_tx( $method => $url => \%headers ); $tx = _agent()->start($tx); my $code = $tx->res->code // 0; croak "$side->{label}: $method $path answered $code" if !$code || $code >= $HTTP_ERROR; for my $cookie ( @{ $tx->res->cookies } ) { $member->{jar}{ $cookie->name } = $cookie->value; } return $tx; } sub _field ( $tx, $name ) { my $input = $tx->res->dom->at(qq{input[name="$name"]}) // croak "no $name field on " . $tx->req->url; return $input->attr('value'); } # Every cell on every side, the sides' order reversed each round. sub _measure ( $sides, $option ) { my @rows; for my $round ( 1 .. $option->{rounds} ) { my @order = $round % 2 ? @{$sides} : reverse @{$sides}; for my $page ( @{ $option->{page} } ) { for my $concurrency ( @{ $option->{concurrency} } ) { for my $side (@order) { my $row = _cell( $side, $page, $concurrency, $option ); $row->{round} = $round; push @rows, $row; _log_row( $row, $option ); } } } } return \@rows; } sub _log_row ( $row, $option ) { printf {*STDERR} 'r%d %-14s c=%-4d %-10s %8.1f req/s p50 %8.2f p95 %8.2f' . " p99 %8.2f%s%s\n", @{$row}{qw(round page concurrency side rps p50_ms p95_ms p99_ms)}, ( $row->{connections} ? " connections $row->{connections}" : q{} ), ( $row->{burst} ? sprintf( q{ burst p50 %.2f p95 %.2f p99 %.2f}, @{ $row->{burst} }{qw(p50_ms p95_ms p99_ms)} ) : q{} ) or croak q{cannot write the progress}; return if !defined $option->{json}; open my $handle, '>>', $option->{json} or croak "cannot append to $option->{json}: $OS_ERROR"; print {$handle} encode_json($row) . "\n" or croak "cannot write $option->{json}"; close $handle or croak "cannot close $option->{json}"; return; } sub _cell ( $side, $page, $concurrency, $option ) { my $definition = { %{ $PAGE{$page} } }; my $writer = $definition->{who} eq 'writer'; if ($writer) { # The bench's own counters: each member may write 20 times a minute. my $dbh = _connect( $side->{dsn} ); $dbh->do('DELETE FROM rate_limit_buckets'); $dbh->disconnect; } my %load = ( target => $side->{target}, request => _request_template( $side, $definition ), concurrency => $concurrency, duration => $writer ? $option->{duration} / 2 : $option->{duration}, warmup => $writer ? $WRITE_WARMUP : $option->{warmup}, procs => $concurrency >= $option->{procs} ? $option->{procs} : 1, burst => $writer ? 0 : $option->{burst}, ); my $census = $option->{connections} ? _census_start($side) : undef; my $result = _load( \%load, $census ); return { %{$result}, side => $side->{label}, page => $page, concurrency => $concurrency, }; } sub _request_template ( $side, $definition ) { my @members = @{ $side->{members_signed_in} }; my $who = $definition->{who}; my $paths = $definition->{path} ? [ $definition->{path} ] : [ map { sprintf '/t/018f1004-%04x-7000-8000-%012x/replies', $_, $_ } @REPLY_THREAD ]; my %headers = ( %HEADERS, %{ $definition->{headers} // {} } ); return { method => $definition->{method} // 'GET', paths => $paths, head => join( q{}, "Host: $HOST\r\n", map { "$_: $headers{$_}\r\n" } sort keys %headers ), body => $definition->{body}, members => $who eq 'guest' ? [ { address => '10.9.0.1' } ] : $who eq 'member' ? [ @members[ 0 .. $READ_MEMBERS - 1 ] ] : \@members, }; } # The load: its processes each run their share of the connections and write # what they measured to a file, merged here. sub _load ( $load, $census ) { my $procs = $load->{procs}; my $start = clock_gettime(CLOCK_MONOTONIC) + $START_DELAY; my @children; for my $index ( 0 .. $procs - 1 ) { my $share = int( $load->{concurrency} / $procs ) + ( $index < $load->{concurrency} % $procs ? 1 : 0 ); push @children, _spawn_client( $load, { index => $index, share => $share, start => $start } ); } my $burst = _spawn_burst( $load, $start ); my $connections; if ($census) { sleep $START_DELAY + $load->{warmup} + $load->{duration} / 2; $connections = _census_count($census); } my $merged = _merge( $load, \@children, $connections ); if ($burst) { $merged->{burst} = _merge( $burst->{load}, [ $burst->{child} ], undef ); } return $merged; } # --burst N: N more connections opened at once a quarter of the way into # the measured time, next to the busy ones, and measured on their own until # the end: a proxy opening upstream connections when the load rises. sub _spawn_burst ( $load, $start ) { return undef if !$load->{burst}; my %burst = ( %{$load}, warmup => 0, duration => $load->{duration} * $BURST_SHARE, ); my $opens = $start + $load->{warmup} + $load->{duration} - $burst{duration}; return { load => \%burst, child => _spawn_client( \%burst, { index => $load->{procs}, share => $load->{burst}, start => $opens, } ), }; } sub _spawn_client ( $load, $part ) { my ( $handle, $file ) = tempfile( 'gpforum-bench-socket-XXXXXX', TMPDIR => 1 ); close $handle or croak "cannot close $file"; my $pid = fork // croak "cannot fork: $OS_ERROR"; if ( !$pid ) { my $measured = _client( $load, $part ); open my $out, '>', $file or _exit(1); print {$out} encode_json($measured) or _exit(1); close $out or _exit(1); _exit(0); } return { pid => $pid, file => $file }; } sub _merge ( $load, $children, $connections ) { my ( @latency, %status ); my ( $errors, $bytes ) = ( 0, 0 ); for my $child ( @{$children} ) { waitpid $child->{pid}, 0; my $measured = decode_json( _slurp( $child->{file} ) ); unlink $child->{file}; push @latency, @{ $measured->{latency} }; for my $code ( keys %{ $measured->{status} } ) { $status{$code} += $measured->{status}{$code}; } $errors += $measured->{errors}; $bytes += $measured->{bytes}; } @latency = sort { $a <=> $b } @latency; return { rps => @latency / $load->{duration}, p50_ms => _ms( percentile( \@latency, $P50 ) ), p95_ms => _ms( percentile( \@latency, $P95 ) ), p99_ms => _ms( percentile( \@latency, $P99 ) ), requests => scalar @latency, errors => $errors, status => \%status, bytes => @latency ? int( $bytes / @latency ) : 0, ( defined $connections ? ( connections => $connections ) : () ), }; } sub _slurp ($file) { open my $handle, '<', $file or croak "a load process failed: $file"; local $INPUT_RECORD_SEPARATOR = undef; my $text = <$handle>; close $handle or croak "cannot close $file"; return $text; } sub _ms ($seconds) { return 0 + sprintf '%.2f', $seconds * $MILLISECONDS; } # One load process: its connections, each sending its next request when # the last is answered, from the start for warm-up plus duration seconds; # what was answered after the warm-up is measured. sub _client ( $load, $part ) { # The kernel queue the sign-in's requests opened in the parent is not # this process's: libev makes its own again before the loop runs. Its # clock is the one it read when the parent's loop last ran, minutes ago # by now, and the timers below would count from then. EV::default_loop()->loop_fork; EV::now_update(); srand( $PROCESS_ID ^ time ); my $state = { load => $load, index => $part->{index}, member => $part->{index}, sequence => 0, measured => $part->{start} + $load->{warmup}, ends => $part->{start} + $load->{warmup} + $load->{duration}, latency => [], status => {}, errors => 0, bytes => 0, open => $part->{share}, }; my @connections = map { {} } 1 .. $part->{share}; my $go = EV::timer( _until( $part->{start} ), 0, sub { for my $connection (@connections) { _send( $state, $connection ); } } ); my $guard = EV::timer( _until( $state->{ends} ) + $GRACE, 0, sub { EV::break(); } ); EV::run(); return { latency => $state->{latency}, status => $state->{status}, errors => $state->{errors}, bytes => $state->{bytes}, }; } sub _until ($when) { my $delay = $when - clock_gettime(CLOCK_MONOTONIC); return $delay > 0 ? $delay : 0; } sub _send ( $state, $connection ) { my $now = clock_gettime(CLOCK_MONOTONIC); if ( $now >= $state->{ends} ) { _close($connection); if ( !--$state->{open} ) { EV::break(); } return; } if ( !$connection->{handle} && !_open( $state, $connection ) ) { $connection->{retry} = EV::timer( $RECONNECT, 0, sub { _send( $state, $connection ); } ); return; } $connection->{out} = _next_request($state); $connection->{started} = $now; $connection->{in} = q{}; $connection->{watcher} = EV::io( $connection->{handle}, EV::WRITE, sub { _write( $state, $connection ); } ); return; } sub _open ( $state, $connection ) { my ( $kind, $where ) = split /:/msx, $state->{load}{target}, 2; my $handle; if ( $kind eq 'unix' ) { socket $handle, AF_UNIX, SOCK_STREAM, 0 or croak "socket: $OS_ERROR"; } else { socket $handle, AF_INET, SOCK_STREAM, 0 or croak "socket: $OS_ERROR"; setsockopt $handle, IPPROTO_TCP, TCP_NODELAY, 1 or croak "setsockopt: $OS_ERROR"; } my $flags = fcntl $handle, F_GETFL, 0 or croak "fcntl: $OS_ERROR"; fcntl $handle, F_SETFL, $flags | O_NONBLOCK or croak "fcntl: $OS_ERROR"; my $address = $kind eq 'unix' ? pack_sockaddr_un($where) : pack_sockaddr_in( ( split /:/msx, $where )[1], inet_aton('127.0.0.1') ); if ( !connect $handle, $address ) { if ( $OS_ERROR != EINPROGRESS && $OS_ERROR != EAGAIN ) { ++$state->{errors}; return 0; } } $connection->{handle} = $handle; return 1; } sub _next_request ($state) { my $template = $state->{load}{request}; my $members = $template->{members}; my $member = $members->[ $state->{member} % @{$members} ]; $state->{member} += $state->{load}{procs}; my $sequence = ++$state->{sequence}; my $paths = $template->{paths}; my $path = $paths->[ ( $sequence * $state->{load}{procs} + $state->{index} ) % @{$paths} ]; my $address = $member->{address}; my $request = "$template->{method} $path HTTP/1.1\r\n$template->{head}" . "X-Forwarded-For: $address\r\nX-Real-IP: $address\r\n" . ( defined $member->{cookie} ? "Cookie: $member->{cookie}\r\n" : q{} ); return "$request\r\n" if !defined $template->{body}; my $body = _body( $template->{body}, $member, "$PROCESS_ID-$sequence" ); return $request . "Content-Type: application/x-www-form-urlencoded\r\n" . 'Content-Length: ' . length($body) . "\r\n\r\n$body"; } sub _body ( $template, $member, $number ) { my $body = $template; my $token = url_escape( $member->{csrf} // q{} ); $body =~ s/[{]csrf[}]/$token/gmsx; $body =~ s/[{]uuid[}]/_uuid()/egmsx; $body =~ s/[{]n[}]/$number/gmsx; return $body; } sub _uuid { my $hex = join q{}, map { sprintf '%02x', int rand $BYTE } 1 .. 16; ## no critic (ValuesAndExpressions::ProhibitMagicNumbers) -- a uuid's sixteen bytes substr $hex, 12, 1, '4'; ## no critic (ValuesAndExpressions::ProhibitMagicNumbers) -- the version digit return join q{-}, unpack 'A8 A4 A4 A4 A12', $hex; } sub _write ( $state, $connection ) { my $written = syswrite $connection->{handle}, $connection->{out}; if ( !defined $written ) { return if $OS_ERROR == EAGAIN || $OS_ERROR == EINTR; return _failed( $state, $connection ); } substr $connection->{out}, 0, $written, q{}; return if length $connection->{out}; $connection->{watcher} = EV::io( $connection->{handle}, EV::READ, sub { _read( $state, $connection ); } ); return; } sub _failed ( $state, $connection ) { ++$state->{errors}; _close($connection); _send( $state, $connection ); return; } sub _close ($connection) { undef $connection->{watcher}; if ( $connection->{handle} ) { close $connection->{handle} or undef; } undef $connection->{handle}; return; } sub _read ( $state, $connection ) { my $read = sysread $connection->{handle}, $connection->{in}, $BUFFER, length $connection->{in}; if ( !defined $read ) { return if $OS_ERROR == EAGAIN || $OS_ERROR == EINTR; return _failed( $state, $connection ); } return _failed( $state, $connection ) if !$read; my $response = _response( $connection->{in} ) // return; my $now = clock_gettime(CLOCK_MONOTONIC); if ( $connection->{started} >= $state->{measured} && $now <= $state->{ends} ) { push @{ $state->{latency} }, $now - $connection->{started}; ++$state->{status}{ $response->{code} }; $state->{bytes} += $response->{body}; } if ( $response->{close} ) { _close($connection); } _send( $state, $connection ); return; } # The response in a buffer, once it is whole: its status, its body's length, # and whether the server closes the connection after it. sub _response ($buffer) { my $end = index $buffer, $CRLF2; return undef if $end < 0; my $head = substr $buffer, 0, $end; my ($code) = $head =~ m{\A HTTP/1[.][01] [ ] (\d{3})}msx; my $closes = $head =~ /^Connection: \s* close/imsx ? 1 : 0; my $body_at = $end + length $CRLF2; if ( $head =~ /^Content-Length: \s* (\d+)/imsx ) { return undef if length $buffer < $body_at + $1; return { code => $code, body => $1, close => $closes }; } if ( $head =~ /^Transfer-Encoding: \s* chunked/imsx ) { return undef if $buffer !~ /\r\n0\r\n\r\n\z/msx; return { code => $code, body => length($buffer) - $body_at, close => $closes }; } return { code => $code, body => length($buffer) - $body_at, close => 1 }; } # The connections each worker holds, before a cell and in its middle: the # difference is the cell's, by worker. It needs pgrep and lsof. sub _census_start ($side) { return { side => $side, before => _worker_sockets($side) }; } sub _census_count ($census) { my $now = _worker_sockets( $census->{side} ); my $before = $census->{before}; return join q{/}, reverse sort { $a <=> $b } map { $now->{$_} - ( $before->{$_} // 0 ) } keys %{$now}; } sub _worker_sockets ($side) { open my $file, '<', "$side->{run}/hypnotoad.pid" or croak "no pid file in $side->{run}"; my $manager = <$file>; close $file or croak 'cannot close the pid file'; chomp $manager; my %sockets; for my $worker ( _command_lines( 'pgrep', '-P', $manager ) ) { my @open = $side->{tcp} ? _command_lines( 'lsof', '-nP', '-a', '-p', $worker, '-iTCP', '-sTCP:ESTABLISHED' ) : _command_lines( 'lsof', '-nP', '-a', '-p', $worker, '-U' ); $sockets{$worker} = @open ? @open - 1 : 0; } return \%sockets; } sub _command_lines (@command) { open my $output, q{-|}, @command or croak "cannot run $command[0]: $OS_ERROR"; my @lines = <$output>; chomp @lines; close $output or undef; return @lines; } sub _finish ( $sides, $option ) { for my $side ( @{$sides} ) { _stop($side); next if !$side->{created} || $option->{keep}; my $admin = _connect( $side->{admin} ); $admin->do( 'DROP DATABASE IF EXISTS ' . $admin->quote_identifier( $side->{database} ) . ' WITH (FORCE)' ); $admin->disconnect; } return; } # The manager in the foreground stops its workers on TERM; anything still # running after that is killed with its session. sub _stop ($side) { return if !$side->{pid}; kill 'TERM', $side->{pid}; my $deadline = clock_gettime(CLOCK_MONOTONIC) + $STOP_WAIT; while ( clock_gettime(CLOCK_MONOTONIC) < $deadline ) { return if waitpid( $side->{pid}, WNOHANG ) == $side->{pid}; sleep $POLL; } kill 'KILL', -$side->{pid}; waitpid $side->{pid}, 0; return; } # Each cell's median over the rounds, and the change from the first side. sub _report ( $sides, $rows, $option ) { my %cell; for my $row ( @{$rows} ) { push @{ $cell{ $row->{page} }{ $row->{concurrency} }{ $row->{side} } }, $row; } my $first = $sides->[0]{label}; my $text = sprintf q{socket bench: %d workers, %d rounds of %s s after %s s} . " warm-up, medians\n", @{$option}{qw(workers rounds duration warmup)}; $text .= sprintf "%-14s %5s %-10s %9s %8s %8s %8s %7s %7s\n", 'page', 'c', 'side', 'req/s', 'p50 ms', 'p95 ms', 'p99 ms', 'req/s', 'errors'; for my $page ( @{ $option->{page} } ) { for my $concurrency ( @{ $option->{concurrency} } ) { my $base = _median_row( $cell{$page}{$concurrency}{$first} ); for my $side ( @{$sides} ) { my $rows = $cell{$page}{$concurrency}{ $side->{label} }; my $median = _median_row($rows); $text .= sprintf "%-14s %5d %-10s %9.1f %8.2f %8.2f %8.2f %7s %7d\n", $page, $concurrency, $side->{label}, @{$median}{qw(rps p50_ms p95_ms p99_ms)}, _change( $base->{rps}, $median->{rps} ), $median->{errors}; next if !$option->{burst}; my $burst = _median_row( [ map { $_->{burst} // () } @{ $rows // [] } ] ); $text .= sprintf "%-14s %5s %-10s %9.1f %8.2f %8.2f %8.2f\n", q{}, "+$option->{burst}", $side->{label}, @{$burst}{qw(rps p50_ms p95_ms p99_ms)}; } } } return $text; } sub _median_row ($rows) { my %median; for my $key (qw(rps p50_ms p95_ms p99_ms)) { my @values = sort { $a <=> $b } map { $_->{$key} } @{ $rows // [] }; $median{$key} = @values ? percentile( \@values, $P50 ) : 0; } $median{errors} = sum0 map { $_->{errors} + _refused($_) } @{ $rows // [] }; return \%median; } # Answers that are neither a page nor a redirect, as the errors' column # counts them. sub _refused ($row) { return sum0 map { $row->{status}{$_} } grep { !exists $ANSWERED{$_} } keys %{ $row->{status} }; } sub _change ( $base, $value ) { return q{} if !$base; return sprintf '%+.0f%%', $PERCENT * ( $value / $base - 1 ); }