X-Git-Url: http://www.git.stargrave.org/?a=blobdiff_plain;f=t%2Fhttpd-corner.t;h=4077a6d1a44bdb308dbacdaef2b5c7f7c2fcbef9;hb=420fddb8b683637cc1fd39727896cac4a459c3b6;hp=a6238e4871f09ed4658d4c317de9b05c3f5faf88;hpb=5186d1f71d83ad84104c93da6bd27908a1610403;p=public-inbox.git diff --git a/t/httpd-corner.t b/t/httpd-corner.t index a6238e48..4077a6d1 100644 --- a/t/httpd-corner.t +++ b/t/httpd-corner.t @@ -1,78 +1,71 @@ -# Copyright (C) 2016 all contributors +# Copyright (C) 2016-2019 all contributors # License: AGPL-3.0+ # note: our HTTP server should be standalone and capable of running -# generic Rack apps. +# generic PSGI/Plack apps. use strict; use warnings; use Test::More; +use Time::HiRes qw(gettimeofday tv_interval); +use PublicInbox::Spawn qw(which); -foreach my $mod (qw(Plack::Util Plack::Request Plack::Builder Danga::Socket - HTTP::Parser::XS HTTP::Date HTTP::Status)) { +foreach my $mod (qw(Plack::Util Plack::Builder + HTTP::Date HTTP::Status IPC::Run)) { eval "require $mod"; plan skip_all => "$mod missing for httpd-corner.t" if $@; } use Digest::SHA qw(sha1_hex); use File::Temp qw/tempdir/; -use Cwd qw/getcwd/; use IO::Socket; use IO::Socket::UNIX; -use Fcntl qw(FD_CLOEXEC F_SETFD F_GETFD :seek); -use Socket qw(SO_KEEPALIVE IPPROTO_TCP TCP_NODELAY); -use POSIX qw(dup2 mkfifo :sys_wait_h); +use Fcntl qw(:seek); +use Socket qw(IPPROTO_TCP TCP_NODELAY SOL_SOCKET); +use POSIX qw(mkfifo); +require './t/common.perl'; my $tmpdir = tempdir('httpd-corner-XXXXXX', TMPDIR => 1, CLEANUP => 1); my $fifo = "$tmpdir/fifo"; ok(defined mkfifo($fifo, 0777), 'created FIFO'); my $err = "$tmpdir/stderr.log"; my $out = "$tmpdir/stdout.log"; my $httpd = 'blib/script/public-inbox-httpd'; -my $psgi = getcwd()."/t/httpd-corner.psgi"; -my %opts = ( - LocalAddr => '127.0.0.1', - ReuseAddr => 1, - Proto => 'tcp', - Type => SOCK_STREAM, - Listen => 1024, -); -my $sock = IO::Socket::INET->new(%opts); +my $psgi = "./t/httpd-corner.psgi"; +my $sock = tcp_server(); + +# make sure stdin is not a pipe for lsof test to check for leaking pipes +open(STDIN, '<', '/dev/null') or die 'no /dev/null: $!'; + +# Make sure we don't clobber socket options set by systemd or similar +# using socket activation: +my ($defer_accept_val, $accf_arg); +if ($^O eq 'linux') { + setsockopt($sock, IPPROTO_TCP, Socket::TCP_DEFER_ACCEPT(), 5) or die; + my $x = getsockopt($sock, IPPROTO_TCP, Socket::TCP_DEFER_ACCEPT()); + defined $x or die "getsockopt: $!"; + $defer_accept_val = unpack('i', $x); + if ($defer_accept_val <= 0) { + die "unexpected TCP_DEFER_ACCEPT value: $defer_accept_val"; + } +} elsif ($^O eq 'freebsd' && system('kldstat -m accf_data >/dev/null') == 0) { + require PublicInbox::Daemon; + my $var = PublicInbox::Daemon::SO_ACCEPTFILTER(); + $accf_arg = pack('a16a240', 'dataready', ''); + setsockopt($sock, SOL_SOCKET, $var, $accf_arg) or die "setsockopt: $!"; +} + my $upath = "$tmpdir/s"; -my $unix = IO::Socket::UNIX->new( - Listen => 1024, - Type => SOCK_STREAM, - Local => $upath -); +my $unix = unix_server($upath); ok($unix, 'UNIX socket created'); my $pid; END { kill 'TERM', $pid if defined $pid }; my $spawn_httpd = sub { my (@args) = @_; - $! = 0; - my $fl = fcntl($sock, F_GETFD, 0); - ok(! $!, 'no error from fcntl(F_GETFD)'); - is($fl, FD_CLOEXEC, 'cloexec set by default (Perl behavior)'); - $pid = fork; - if ($pid == 0) { - # pretend to be systemd - dup2(fileno($sock), 3) or die "dup2 failed: $!\n"; - dup2(fileno($unix), 4) or die "dup2 failed: $!\n"; - $sock = IO::Handle->new_from_fd(3, 'r'); - $sock->fcntl(F_SETFD, 0); - $unix = IO::Handle->new_from_fd(4, 'r'); - $unix->fcntl(F_SETFD, 0); - $ENV{LISTEN_PID} = $$; - $ENV{LISTEN_FDS} = 2; - exec $httpd, @args, "--stdout=$out", "--stderr=$err", $psgi; - die "FAIL: $!\n"; - } + my $cmd = [ $httpd, @args, "--stdout=$out", "--stderr=$err", $psgi ]; + $pid = spawn_listener(undef, $cmd, [ $sock, $unix ]); ok(defined $pid, 'forked httpd process successfully'); }; { ok($sock, 'sock created'); - $! = 0; - my $fl = fcntl($sock, F_GETFD, 0); - ok(! $!, 'no error from fcntl(F_GETFD)'); - is($fl, FD_CLOEXEC, 'cloexec set by default (Perl behavior)'); $spawn_httpd->('-W0'); } @@ -84,6 +77,78 @@ my $spawn_httpd = sub { is($body, "hello world\n", 'callback body matches expected'); } +{ + my $conn = conn_for($sock, 'getline-die'); + $conn->write("GET /getline-die HTTP/1.1\r\nHost: example.com\r\n\r\n"); + ok($conn->read(my $buf, 8192), 'read some response'); + like($buf, qr!HTTP/1\.1 200\b[^\r]*\r\n!, 'got some sort of header'); + is($conn->read(my $nil, 8192), 0, 'read EOF'); + $conn = undef; + my $after = capture($err); + is(scalar(grep(/GETLINE FAIL/, @$after)), 1, 'failure logged'); + is(scalar(grep(/CLOSE FAIL/, @$after)), 1, 'body->close not called'); +} + +{ + my $conn = conn_for($sock, 'close-die'); + $conn->write("GET /close-die HTTP/1.1\r\nHost: example.com\r\n\r\n"); + ok($conn->read(my $buf, 8192), 'read some response'); + like($buf, qr!HTTP/1\.1 200\b[^\r]*\r\n!, 'got some sort of header'); + is($conn->read(my $nil, 8192), 0, 'read EOF'); + $conn = undef; + my $after = capture($err); + is(scalar(grep(/GETLINE FAIL/, @$after)), 0, 'getline not failed'); + is(scalar(grep(/CLOSE FAIL/, @$after)), 1, 'body->close not called'); +} + +SKIP: { + my $conn = conn_for($sock, 'excessive header'); + $SIG{PIPE} = 'IGNORE'; + $conn->write("GET /callback HTTP/1.0\r\n"); + foreach my $i (1..500000) { + last unless $conn->write("X-xxxxxJunk-$i: omg\r\n"); + } + ok(!$conn->write("\r\n"), 'broken request'); + ok($conn->read(my $buf, 8192), 'read response'); + my ($head, $body) = split(/\r\n\r\n/, $buf); + like($head, qr/\b400\b/, 'got 400 response'); +} + +{ + my $conn = conn_for($sock, 'excessive body Content-Length'); + $SIG{PIPE} = 'IGNORE'; + my $n = (10 * 1024 * 1024) + 1; + $conn->write("PUT /sha1 HTTP/1.0\r\nContent-Length: $n\r\n\r\n"); + ok($conn->read(my $buf, 8192), 'read response'); + my ($head, $body) = split(/\r\n\r\n/, $buf); + like($head, qr/\b413\b/, 'got 413 response'); +} + +{ + my $conn = conn_for($sock, 'excessive body chunked'); + $SIG{PIPE} = 'IGNORE'; + my $n = (10 * 1024 * 1024) + 1; + $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding: chunked\r\n"); + $conn->write("\r\n".sprintf("%x\r\n", $n)); + ok($conn->read(my $buf, 8192), 'read response'); + my ($head, $body) = split(/\r\n\r\n/, $buf); + like($head, qr/\b413\b/, 'got 413 response'); +} + +{ + my $conn = conn_for($sock, 'chunk with pipeline'); + my $n = 10; + my $payload = 'b'x$n; + $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding: chunked\r\n"); + $conn->write("\r\n".sprintf("%x\r\n", $n)); + $conn->write($payload . "\r\n0\r\n\r\nGET /empty HTTP/1.0\r\n\r\n"); + $conn->read(my $buf, 4096); + my $lim = 0; + $lim++ while ($conn->read($buf, 4096, bytes::length($buf)) && $lim < 9); + my $exp = sha1_hex($payload); + like($buf, qr!\r\n\r\n${exp}HTTP/1\.0 200 OK\r\n!s, + 'chunk parser can handled pipelined requests'); +} # Unix domain sockets { @@ -96,14 +161,9 @@ my $spawn_httpd = sub { } sub conn_for { - my ($sock, $msg) = @_; - my $conn = IO::Socket::INET->new( - PeerAddr => $sock->sockhost, - PeerPort => $sock->sockport, - Proto => 'tcp', - Type => SOCK_STREAM); + my ($dest, $msg) = @_; + my $conn = tcp_connect($dest); ok($conn, "connected for $msg"); - $conn->autoflush(1); setsockopt($conn, IPPROTO_TCP, TCP_NODELAY, 1); return $conn; } @@ -184,28 +244,14 @@ my $check_self = sub { }; SKIP: { - use POSIX qw(dup2); - use IO::File; - my $have_curl = 0; - foreach my $p (split(':', $ENV{PATH})) { - -x "$p/curl" or next; - $have_curl = 1; - last; - } - my $ntest = 2; - $have_curl or skip('curl(1) missing', $ntest); - my $url = 'http://' . $sock->sockhost . ':' . $sock->sockport . '/sha1'; + which('curl') or skip('curl(1) missing', 4); + my $base = 'http://' . $sock->sockhost . ':' . $sock->sockport; + my $url = "$base/sha1"; my ($r, $w); pipe($r, $w) or die "pipe: $!"; - my $tout = IO::File->new_tmpfile or die "new_tmpfile: $!"; - my $pid = fork; - defined $pid or die "fork: $!"; - my @cmd = (qw(curl --tcp-nodelay --no-buffer -T- -HExpect: -sS), $url); - if ($pid == 0) { - dup2(fileno($r), 0) or die "redirect stdin failed: $!\n"; - dup2(fileno($tout), 1) or die "redirect stdout failed: $!\n"; - exec(@cmd) or die 'exec `' . join(' '). "' failed: $!\n"; - } + my $cmd = [qw(curl --tcp-nodelay --no-buffer -T- -HExpect: -sS), $url]; + my ($out, $err) = ('', ''); + my $h = IPC::Run::start($cmd, $r, \$out, \$err); $w->autoflush(1); foreach my $c ('a'..'z') { print $w $c or die "failed to write to curl: $!"; @@ -213,11 +259,21 @@ SKIP: { } close $w or die "close write pipe: $!"; close $r or die "close read pipe: $!"; - my $kid = waitpid $pid, 0; + IPC::Run::finish($h); is($?, 0, 'curl exited successfully'); - $tout->sysseek(0, SEEK_SET); - $tout->sysread(my $buf, 100); - is($buf, sha1_hex($str), 'read expected body'); + is($err, '', 'no errors from curl'); + is($out, sha1_hex($str), 'read expected body'); + + open my $fh, '-|', qw(curl -sS), "$base/async-big" or die $!; + my $n = 0; + my $non_zero = 0; + while (1) { + my $r = sysread($fh, my $buf, 4096) or last; + $n += $r; + $buf =~ /\A\0+\z/ or $non_zero++; + } + is($n, 30 * 1024 * 1024, 'got expected output from curl'); + is($non_zero, 0, 'read all zeros'); } { @@ -241,6 +297,18 @@ SKIP: { } } +{ + my $conn = conn_for($sock, 'no TCP_CORK on empty body'); + $conn->write("GET /empty HTTP/1.1\r\nHost:example.com\r\n\r\n"); + my $buf = ''; + my $t0 = [ gettimeofday ]; + until ($buf =~ /\r\n\r\n/s) { + $conn->sysread($buf, 4096, length($buf)); + } + my $elapsed = tv_interval($t0, [ gettimeofday ]); + ok($elapsed < 0.190, 'no 200ms TCP cork delay on empty body'); +} + { my $conn = conn_for($sock, 'graceful termination during slow request'); $conn->write("PUT /sha1 HTTP/1.0\r\n"); @@ -441,6 +509,37 @@ SKIP: { is($body, sha1_hex(''), 'read expected body #2'); } +SKIP: { + skip 'TCP_DEFER_ACCEPT is Linux-only', 1 if $^O ne 'linux'; + my $var = Socket::TCP_DEFER_ACCEPT(); + defined(my $x = getsockopt($sock, IPPROTO_TCP, $var)) or die; + is(unpack('i', $x), $defer_accept_val, + 'TCP_DEFER_ACCEPT unchanged if previously set'); +}; +SKIP: { + skip 'SO_ACCEPTFILTER is FreeBSD-only', 1 if $^O ne 'freebsd'; + skip 'accf_data not loaded: kldload accf_data' if !defined $accf_arg; + my $var = PublicInbox::Daemon::SO_ACCEPTFILTER(); + defined(my $x = getsockopt($sock, SOL_SOCKET, $var)) or die; + is($x, $accf_arg, 'SO_ACCEPTFILTER unchanged if previously set'); +}; +SKIP: { + skip 'only testing lsof(8) output on Linux', 1 if $^O ne 'linux'; + skip 'no lsof in PATH', 1 unless which('lsof'); + my @lsof = `lsof -p $pid`; + is_deeply([grep(/\bdeleted\b/, @lsof)], [], 'no lingering deleted inputs'); + is_deeply([grep(/\bpipe\b/, @lsof)], [], 'no extra pipes with -W0'); +}; + done_testing(); +sub capture { + my ($f) = @_; + open my $fh, '+<', $f or die "failed to open $f: $!\n"; + local $/ = "\n"; + my @r = <$fh>; + truncate($fh, 0) or die "truncate failed on $f: $!\n"; + \@r +} + 1;