]> Sergey Matveev's repositories - public-inbox.git/blob - t/httpd-corner.t
cec754c9c13ae2a051a3586275c6a838838cf777
[public-inbox.git] / t / httpd-corner.t
1 # Copyright (C) 2016-2021 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3 # note: our HTTP server should be standalone and capable of running
4 # generic PSGI/Plack apps.
5 use strict; use v5.10.1; use PublicInbox::TestCommon;
6 use Time::HiRes qw(gettimeofday tv_interval);
7 use PublicInbox::Spawn qw(spawn popen_rd);
8 require_mods(qw(Plack::Util Plack::Builder HTTP::Date HTTP::Status));
9 use Digest::SHA qw(sha1_hex);
10 use IO::Handle ();
11 use IO::Socket::UNIX;
12 use Fcntl qw(:seek);
13 use Socket qw(IPPROTO_TCP TCP_NODELAY SOL_SOCKET);
14 use POSIX qw(mkfifo);
15 use Carp ();
16 my ($tmpdir, $for_destroy) = tmpdir();
17 my $fifo = "$tmpdir/fifo";
18 ok(defined mkfifo($fifo, 0777), 'created FIFO');
19 my $err = "$tmpdir/stderr.log";
20 my $out = "$tmpdir/stdout.log";
21 my $psgi = "./t/httpd-corner.psgi";
22 my $sock = tcp_server() or die;
23 my @zmods = qw(PublicInbox::GzipFilter IO::Uncompress::Gunzip);
24
25 # Make sure we don't clobber socket options set by systemd or similar
26 # using socket activation:
27 my ($defer_accept_val, $accf_arg, $TCP_DEFER_ACCEPT);
28 if ($^O eq 'linux') {
29         $TCP_DEFER_ACCEPT = eval { Socket::TCP_DEFER_ACCEPT() } // 9;
30         setsockopt($sock, IPPROTO_TCP, $TCP_DEFER_ACCEPT, 5) or die;
31         my $x = getsockopt($sock, IPPROTO_TCP, $TCP_DEFER_ACCEPT);
32         defined $x or die "getsockopt: $!";
33         $defer_accept_val = unpack('i', $x);
34         if ($defer_accept_val <= 0) {
35                 die "unexpected TCP_DEFER_ACCEPT value: $defer_accept_val";
36         }
37 } elsif ($^O eq 'freebsd' && system('kldstat -m accf_data >/dev/null') == 0) {
38         require PublicInbox::Daemon;
39         my $var = $PublicInbox::Daemon::SO_ACCEPTFILTER;
40         $accf_arg = pack('a16a240', 'dataready', '');
41         setsockopt($sock, SOL_SOCKET, $var, $accf_arg) or die "setsockopt: $!";
42 }
43
44 sub unix_server ($) {
45         my $s = IO::Socket::UNIX->new(
46                 Listen => 1024,
47                 Type => Socket::SOCK_STREAM(),
48                 Local => $_[0],
49         ) or BAIL_OUT "bind + listen $_[0]: $!";
50         $s->blocking(0);
51         $s;
52 }
53
54 my $upath = "$tmpdir/s";
55 my $unix = unix_server($upath);
56 my $td;
57 my $spawn_httpd = sub {
58         my (@args) = @_;
59         my $cmd = [ '-httpd', @args, "--stdout=$out", "--stderr=$err", $psgi ];
60         $td = start_script($cmd, undef, { 3 => $sock, 4 => $unix });
61 };
62
63 $spawn_httpd->();
64 if ('test worker death') {
65         my $conn = conn_for($sock, 'killed worker');
66         $conn->write("GET /pid HTTP/1.1\r\nHost:example.com\r\n\r\n");
67         my $pid;
68         while (defined(my $line = $conn->getline)) {
69                 next unless $line eq "\r\n";
70                 chomp($pid = $conn->getline);
71                 last;
72         }
73         like($pid, qr/\A[0-9]+\z/, '/pid response');
74         is(kill('KILL', $pid), 1, 'killed worker');
75         is($conn->getline, undef, 'worker died and EOF-ed client');
76
77         $conn = conn_for($sock, 'respawned worker');
78         $conn->write("GET /pid HTTP/1.0\r\n\r\n");
79         ok($conn->read(my $buf, 8192), 'read response');
80         my ($head, $body) = split(/\r\n\r\n/, $buf);
81         chomp($body);
82         like($body, qr/\A[0-9]+\z/, '/pid response');
83         isnt($body, $pid, 'respawned worker');
84 }
85
86 {
87         my $conn = conn_for($sock, 'streaming callback');
88         $conn->write("GET /callback HTTP/1.0\r\n\r\n");
89         ok($conn->read(my $buf, 8192), 'read response');
90         my ($head, $body) = split(/\r\n\r\n/, $buf);
91         is($body, "hello world\n", 'callback body matches expected');
92 }
93
94 {
95         my $conn = conn_for($sock, 'getline-die');
96         $conn->write("GET /getline-die HTTP/1.1\r\nHost: example.com\r\n\r\n");
97         ok($conn->read(my $buf, 8192), 'read some response');
98         like($buf, qr!HTTP/1\.1 200\b[^\r]*\r\n!, 'got some sort of header');
99         is($conn->read(my $nil, 8192), 0, 'read EOF');
100         $conn = undef;
101         my $after = capture($err);
102         is(scalar(grep(/GETLINE FAIL/, @$after)), 1, 'failure logged');
103         is(scalar(grep(/CLOSE FAIL/, @$after)), 1, 'body->close not called');
104 }
105
106 {
107         my $conn = conn_for($sock, 'close-die');
108         $conn->write("GET /close-die HTTP/1.1\r\nHost: example.com\r\n\r\n");
109         ok($conn->read(my $buf, 8192), 'read some response');
110         like($buf, qr!HTTP/1\.1 200\b[^\r]*\r\n!, 'got some sort of header');
111         is($conn->read(my $nil, 8192), 0, 'read EOF');
112         $conn = undef;
113         my $after = capture($err);
114         is(scalar(grep(/GETLINE FAIL/, @$after)), 0, 'getline not failed');
115         is(scalar(grep(/CLOSE FAIL/, @$after)), 1, 'body->close not called');
116 }
117
118 sub check_400 {
119         my ($conn) = @_;
120         my $r = $conn->read(my $buf, 8192);
121         # ECONNRESET and $r==0 are both observed on FreeBSD 11.2
122         if (!defined($r)) {
123                 ok($!{ECONNRESET}, 'ECONNRESET on read (BSD sometimes)');
124         } elsif ($r > 0) {
125                 like($buf, qr!\AHTTP/1\.\d 400 !, 'got 400 response');
126         } else {
127                 is($r, 0, 'got EOF (BSD sometimes)');
128         }
129         close($conn); # ensure we don't get SIGPIPE later
130 }
131
132 {
133         local $SIG{PIPE} = 'IGNORE';
134         my $conn = conn_for($sock, 'excessive header');
135         $conn->write("GET /callback HTTP/1.0\r\n");
136         foreach my $i (1..500000) {
137                 last unless $conn->write("X-xxxxxJunk-$i: omg\r\n");
138         }
139         ok(!$conn->write("\r\n"), 'broken request');
140         check_400($conn);
141 }
142
143 {
144         my $conn = conn_for($sock, 'excessive body Content-Length');
145         my $n = (10 * 1024 * 1024) + 1;
146         $conn->write("PUT /sha1 HTTP/1.0\r\nContent-Length: $n\r\n\r\n");
147         my $r = $conn->read(my $buf, 8192);
148         ok($r > 0, 'read response');
149         my ($head, $body) = split(/\r\n\r\n/, $buf);
150         like($head, qr/\b413\b/, 'got 413 response');
151 }
152
153 {
154         my $conn = conn_for($sock, 'excessive body chunked');
155         my $n = (10 * 1024 * 1024) + 1;
156         $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding: chunked\r\n");
157         $conn->write("\r\n".sprintf("%x\r\n", $n));
158         my $r = $conn->read(my $buf, 8192);
159         ok($r > 0, 'read response');
160         my ($head, $body) = split(/\r\n\r\n/, $buf);
161         like($head, qr/\b413\b/, 'got 413 response');
162 }
163
164 {
165         my $conn = conn_for($sock, '1.1 Transfer-Encoding bogus');
166         $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding: bogus\r\n\r\n");
167         $conn->read(my $buf, 4096);
168         like($buf, qr!\AHTTP/1\.[0-9] 400 !, 'got 400 response on bogus TE');
169 }
170 {
171         my $conn = conn_for($sock, '1.1 Content-Length bogus');
172         $conn->write("PUT /sha1 HTTP/1.1\r\nContent-Length: 3.3\r\n\r\n");
173         $conn->read(my $buf, 4096);
174         like($buf, qr!\AHTTP/1\.[0-9] 400 !, 'got 400 response on bad length');
175 }
176
177 {
178         my $req = "PUT /sha1 HTTP/1.1\r\nContent-Length: 3\r\n" .
179                         "Content-Length: 3\r\n\r\n";
180         # this is stricter than it needs to be.  Due to the way
181         # Plack::HTTPParser, PSGI specs, and how hash tables work in common
182         # languages; it's not possible to tell the difference between folded
183         # and intentionally bad commas (e.g. "Content-Length: 3, 3")
184         if (0) {
185                 require Plack::HTTPParser; # XS or pure Perl
186                 require Data::Dumper;
187                 Plack::HTTPParser::parse_http_request($req, my $env = {});
188                 diag Data::Dumper::Dumper($env); # "Content-Length: 3, 3"
189         }
190         my $conn = conn_for($sock, '1.1 Content-Length dupe');
191         $conn->write($req);
192         $conn->read(my $buf, 4096);
193         like($buf, qr!\AHTTP/1\.[0-9] 400 !, 'got 400 response on dupe length');
194 }
195
196 {
197         my $conn = conn_for($sock, 'chunk with pipeline');
198         my $n = 10;
199         my $payload = 'b'x$n;
200         $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding: chunked\r\n");
201         $conn->write("\r\n".sprintf("%x\r\n", $n));
202         $conn->write($payload . "\r\n0\r\n\r\nGET /empty HTTP/1.0\r\n\r\n");
203         $conn->read(my $buf, 4096);
204         my $lim = 0;
205         $lim++ while ($conn->read($buf, 4096, length($buf)) && $lim < 9);
206         my $exp = sha1_hex($payload);
207         like($buf, qr!\r\n\r\n${exp}HTTP/1\.0 200 OK\r\n!s,
208                 'chunk parser can handled pipelined requests');
209 }
210
211 # Unix domain sockets
212 {
213         my $u = IO::Socket::UNIX->new(Type => SOCK_STREAM, Peer => $upath);
214         ok($u, 'unix socket connected');
215         $u->write("GET /host-port HTTP/1.0\r\n\r\n");
216         $u->read(my $buf, 4096);
217         like($buf, qr!\r\n\r\n127\.0\.0\.1 0\z!,
218                 'set REMOTE_ADDR and REMOTE_PORT for Unix socket');
219 }
220
221 sub conn_for {
222         my ($dest, $msg) = @_;
223         my $conn = tcp_connect($dest);
224         ok($conn, "connected for $msg");
225         setsockopt($conn, IPPROTO_TCP, TCP_NODELAY, 1);
226         return $conn;
227 }
228
229 {
230         my $conn = conn_for($sock, 'host-port');
231         $conn->write("GET /host-port HTTP/1.0\r\n\r\n");
232         $conn->read(my $buf, 4096);
233         my ($head, $body) = split(/\r\n\r\n/, $buf);
234         my ($addr, $port) = split(/ /, $body);
235         is($addr, (tcp_host_port($conn))[0], 'host matches addr');
236         is($port, $conn->sockport, 'port matches');
237 }
238
239 # graceful termination
240 {
241         my $conn = conn_for($sock, 'graceful termination via slow header');
242         $conn->write("GET /slow-header HTTP/1.0\r\n" .
243                         "X-Check-Fifo: $fifo\r\n\r\n");
244         open my $f, '>', $fifo or die "open $fifo: $!\n";
245         $f->autoflush(1);
246         ok(print($f "hello\n"), 'wrote something to fifo');
247         is($td->kill, 1, 'started graceful shutdown');
248         ok(print($f "world\n"), 'wrote else to fifo');
249         close $f or die "close fifo: $!\n";
250         $conn->read(my $buf, 8192);
251         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
252         like($head, qr!\AHTTP/1\.[01] 200 OK!, 'got 200 for slow-header');
253         is($body, "hello\nworld\n", 'read expected body');
254         $td->join;
255         is($?, 0, 'no error');
256         $spawn_httpd->('-W0');
257 }
258
259 {
260         my $conn = conn_for($sock, 'graceful termination via slow-body');
261         $conn->write("GET /slow-body HTTP/1.0\r\n" .
262                         "X-Check-Fifo: $fifo\r\n\r\n");
263         open my $f, '>', $fifo or die "open $fifo: $!\n";
264         $f->autoflush(1);
265         my $buf;
266         $conn->sysread($buf, 8192);
267         like($buf, qr!\AHTTP/1\.[01] 200 OK!, 'got 200 for slow-body');
268         like($buf, qr!\r\n\r\n!, 'finished HTTP response header');
269
270         foreach my $c ('a'..'c') {
271                 $c .= "\n";
272                 ok(print($f $c), 'wrote line to fifo');
273                 $conn->sysread($buf, 8192);
274                 is($buf, $c, 'got trickle for reading');
275         }
276         is($td->kill, 1, 'started graceful shutdown');
277         ok(print($f "world\n"), 'wrote else to fifo');
278         close $f or die "close fifo: $!\n";
279         $conn->sysread($buf, 8192);
280         is($buf, "world\n", 'read expected body');
281         is($conn->sysread($buf, 8192), 0, 'got EOF from server');
282         $td->join;
283         is($?, 0, 'no error');
284         $spawn_httpd->('-W0');
285 }
286
287 sub delay { select(undef, undef, undef, shift || rand(0.02)) }
288
289 my $str = 'abcdefghijklmnopqrstuvwxyz';
290 my $len = length $str;
291 is($len, 26, 'got the alphabet');
292 my $check_self = sub {
293         my ($conn) = @_;
294         vec(my $rbits = '', fileno($conn), 1) = 1;
295         select($rbits, undef, undef, 30) or Carp::confess('timed out');
296         $conn->read(my $buf, 4096);
297         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
298         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
299         is($body, sha1_hex($str), 'read expected body');
300 };
301
302 SKIP: {
303         my $curl = require_cmd('curl', 1) or skip('curl(1) missing', 4);
304         my $base = 'http://'.tcp_host_port($sock);
305         my $url = "$base/sha1";
306         my ($r, $w);
307         pipe($r, $w) or die "pipe: $!";
308         my $cmd = [$curl, qw(--tcp-nodelay -T- -HExpect: -sSN), $url];
309         open my $cout, '+>', undef or die;
310         open my $cerr, '>', undef or die;
311         my $rdr = { 0 => $r, 1 => $cout, 2 => $cerr };
312         my $pid = spawn($cmd, undef, $rdr);
313         close $r or die "close read pipe: $!";
314         foreach my $c ('a'..'z') {
315                 print $w $c or die "failed to write to curl: $!";
316                 delay();
317         }
318         close $w or die "close write pipe: $!";
319         waitpid($pid, 0);
320         is($?, 0, 'curl exited successfully');
321         is(-s $cerr, 0, 'no errors from curl');
322         seek($cout, 0, SEEK_SET);
323         is(<$cout>, sha1_hex($str), 'read expected body');
324
325         my $fh = popen_rd([$curl, '-sS', "$base/async-big"]);
326         my $n = 0;
327         my $non_zero = 0;
328         while (1) {
329                 my $r = sysread($fh, my $buf, 4096) or last;
330                 $n += $r;
331                 $buf =~ /\A\0+\z/ or $non_zero++;
332         }
333         close $fh or die "close curl pipe: $!";
334         is($?, 0, 'curl succesful');
335         is($n, 30 * 1024 * 1024, 'got expected output from curl');
336         is($non_zero, 0, 'read all zeros');
337
338         require_mods(@zmods, 4);
339         my $buf = xqx([$curl, '-sS', "$base/psgi-return-gzip"]);
340         is($?, 0, 'curl succesful');
341         IO::Uncompress::Gunzip::gunzip(\$buf => \(my $out));
342         is($out, "hello world\n");
343         my $curl_rdr = { 2 => \(my $curl_err = '') };
344         $buf = xqx([$curl, qw(-sSv --compressed),
345                         "$base/psgi-return-compressible"], undef, $curl_rdr);
346         is($?, 0, 'curl --compressed successful');
347         is($buf, "goodbye world\n", 'gzipped response as expected');
348         like($curl_err, qr/\bContent-Encoding: gzip\b/,
349                 'curl got gzipped response');
350 }
351
352 {
353         my $conn = conn_for($sock, 'psgi_return ENOENT');
354         print $conn "GET /psgi-return-enoent HTTP/1.1\r\n\r\n" or die;
355         my $buf = '';
356         sysread($conn, $buf, 16384, length($buf)) until $buf =~ /\r\n\r\n/;
357         like($buf, qr!HTTP/1\.[01] 500\b!, 'got 500 error on ENOENT');
358 }
359
360 {
361         my $conn = conn_for($sock, '1.1 pipeline together');
362         $conn->write("PUT /sha1 HTTP/1.1\r\nUser-agent: hello\r\n\r\n" .
363                         "PUT /sha1 HTTP/1.1\r\n\r\n");
364         my $buf = '';
365         my @r;
366         until (scalar(@r) >= 2) {
367                 my $r = $conn->sysread(my $tmp, 4096);
368                 die $! unless defined $r;
369                 die "EOF <$buf>" unless $r;
370                 $buf .= $tmp;
371                 @r = ($buf =~ /\r\n\r\n([a-f0-9]{40})/g);
372         }
373         is(2, scalar @r, 'got 2 responses');
374         my $i = 3;
375         foreach my $hex (@r) {
376                 is($hex, sha1_hex(''), "read expected body $i");
377                 $i++;
378         }
379 }
380
381 {
382         my $conn = conn_for($sock, 'no TCP_CORK on empty body');
383         $conn->write("GET /empty HTTP/1.1\r\nHost:example.com\r\n\r\n");
384         my $buf = '';
385         my $t0 = [ gettimeofday ];
386         until ($buf =~ /\r\n\r\n/s) {
387                 $conn->sysread($buf, 4096, length($buf));
388         }
389         my $elapsed = tv_interval($t0, [ gettimeofday ]);
390         ok($elapsed < 0.190, 'no 200ms TCP cork delay on empty body');
391 }
392
393 {
394         my $conn = conn_for($sock, 'graceful termination during slow request');
395         $conn->write("PUT /sha1 HTTP/1.0\r\nContent-Length: $len\r\n\r\n");
396
397         # XXX ugh, want a reliable and non-intrusive way to detect
398         # that the server has started buffering our partial request so we
399         # can reliably test graceful termination.  Maybe making this and
400         # similar tests dependent on Linux strace is a possibility?
401         delay(0.1);
402
403         is($td->kill, 1, 'start graceful shutdown');
404         my $n = 0;
405         foreach my $c ('a'..'z') {
406                 $n += $conn->write($c);
407         }
408         ok(kill(0, $td->{pid}), 'graceful shutdown did not kill httpd');
409         is($n, $len, 'wrote alphabet');
410         $check_self->($conn);
411         $td->join;
412         is($?, 0, 'no error');
413         $spawn_httpd->('-W0');
414 }
415
416 # various DoS attacks against the chunk parser:
417 {
418         local $SIG{PIPE} = 'IGNORE';
419         my $conn = conn_for($sock, '1.1 chunk header excessive');
420         $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding:chunked\r\n\r\n");
421         my $n = 0;
422         my $w;
423         while ($w = $conn->write('ffffffff')) {
424                 $n += $w;
425         }
426         ok($!, 'got error set in $!');
427         is($w, undef, 'write error happened');
428         ok($n > 0, 'was able to write');
429         check_400($conn);
430         $conn = conn_for($sock, '1.1 chunk trailer excessive');
431         $conn->write("PUT /sha1 HTTP/1.1\r\nTransfer-Encoding:chunked\r\n\r\n");
432         is($conn->syswrite("1\r\na"), 4, 'wrote first header + chunk');
433         delay();
434         $n = 0;
435         while ($w = $conn->write("\r")) {
436                 $n += $w;
437         }
438         ok($!, 'got error set in $!');
439         ok($n > 0, 'wrote part of chunk end (\r)');
440         check_400($conn);
441 }
442
443 {
444         my $conn = conn_for($sock, '1.1 chunked close trickle');
445         $conn->write("PUT /sha1 HTTP/1.1\r\nConnection:close\r\n");
446         $conn->write("Transfer-encoding: chunked\r\n\r\n");
447         foreach my $x ('a'..'z') {
448                 delay();
449                 $conn->write('1');
450                 delay();
451                 $conn->write("\r");
452                 delay();
453                 $conn->write("\n");
454                 delay();
455                 $conn->write($x);
456                 delay();
457                 $conn->write("\r");
458                 delay();
459                 $conn->write("\n");
460         }
461         $conn->write('0');
462         delay();
463         $conn->write("\r");
464         delay();
465         $conn->write("\n");
466         delay();
467         $conn->write("\r");
468         delay();
469         $conn->write("\n");
470         delay();
471         $check_self->($conn);
472 }
473
474 {
475         my $conn = conn_for($sock, '1.1 chunked close');
476         $conn->write("PUT /sha1 HTTP/1.1\r\nConnection:close\r\n");
477         my $xlen = sprintf('%x', $len);
478         $conn->write("Transfer-Encoding: chunked\r\n\r\n$xlen\r\n" .
479                 "$str\r\n0\r\n\r\n");
480         $check_self->($conn);
481 }
482
483 {
484         my $conn = conn_for($sock, 'chunked body + pipeline');
485         $conn->write("PUT /sha1 HTTP/1.1\r\n" .
486                         "Transfer-Encoding: chunked\r\n");
487         delay();
488         $conn->write("\r\n1\r\n");
489         delay();
490         $conn->write('a');
491         delay();
492         $conn->write("\r\n0\r\n\r\nPUT /sha1 HTTP/1.1\r\n");
493         delay();
494
495         my $buf = '';
496         until ($buf =~ /\r\n\r\n[a-f0-9]{40}\z/) {
497                 $conn->sysread(my $tmp, 4096);
498                 $buf .= $tmp;
499         }
500         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
501         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
502         is($body, sha1_hex('a'), 'read expected body');
503
504         $conn->write("Connection: close\r\n");
505         $conn->write("Content-Length: $len\r\n\r\n$str");
506         $check_self->($conn);
507 }
508
509 {
510         my $conn = conn_for($sock, 'trickle header, one-shot body + pipeline');
511         $conn->write("PUT /sha1 HTTP/1.0\r\n" .
512                         "Connection: keep-alive\r\n");
513         delay();
514         $conn->write("Content-Length: $len\r\n\r\n${str}PUT");
515         my $buf = '';
516         until ($buf =~ /\r\n\r\n[a-f0-9]{40}\z/) {
517                 $conn->sysread(my $tmp, 4096);
518                 $buf .= $tmp;
519         }
520         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
521         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
522         is($body, sha1_hex($str), 'read expected body');
523
524         $conn->write(" /sha1 HTTP/1.0\r\nContent-Length: $len\r\n\r\n$str");
525         $check_self->($conn);
526 }
527
528 {
529         my $conn = conn_for($sock, 'trickle body');
530         $conn->write("PUT /sha1 HTTP/1.0\r\n");
531         $conn->write("Content-Length: $len\r\n\r\n");
532         my $beg = substr($str, 0, 10);
533         my $end = substr($str, 10);
534         is($beg . $end, $str, 'substr setup correct');
535         delay();
536         $conn->write($beg);
537         delay();
538         $conn->write($end);
539         $check_self->($conn);
540 }
541
542 {
543         my $conn = conn_for($sock, 'one-shot write');
544         $conn->write("PUT /sha1 HTTP/1.0\r\n" .
545                         "Content-Length: $len\r\n\r\n$str");
546         $check_self->($conn);
547 }
548
549 {
550         my $conn = conn_for($sock, 'trickle header, one-shot body');
551         $conn->write("PUT /sha1 HTTP/1.0\r\n");
552         delay();
553         $conn->write("Content-Length: $len\r\n\r\n$str");
554         $check_self->($conn);
555 }
556
557 {
558         my $conn = conn_for($sock, '1.1 Connection: close');
559         $conn->write("PUT /sha1 HTTP/1.1\r\nConnection:close\r\n");
560         delay();
561         $conn->write("Content-Length: $len\r\n\r\n$str");
562         $check_self->($conn);
563 }
564
565 {
566         my $conn = conn_for($sock, '1.1 pipeline start');
567         $conn->write("PUT /sha1 HTTP/1.1\r\n\r\nPUT");
568         my $buf = '';
569         until ($buf =~ /\r\n\r\n[a-f0-9]{40}\z/) {
570                 $conn->sysread(my $tmp, 4096);
571                 $buf .= $tmp;
572         }
573         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
574         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
575         is($body, sha1_hex(''), 'read expected body');
576
577         # 2nd request
578         $conn->write(" /sha1 HTTP/1.1\r\n\r\n");
579         $buf = '';
580         until ($buf =~ /\r\n\r\n[a-f0-9]{40}\z/) {
581                 $conn->sysread(my $tmp, 4096);
582                 $buf .= $tmp;
583         }
584         ($head, $body) = split(/\r\n\r\n/, $buf, 2);
585         like($head, qr/\r\nContent-Length: 40\r\n/s, 'got expected length');
586         is($body, sha1_hex(''), 'read expected body #2');
587 }
588
589 SKIP: {
590         skip 'TCP_DEFER_ACCEPT is Linux-only', 1 if $^O ne 'linux';
591         my $var = $TCP_DEFER_ACCEPT;
592         defined(my $x = getsockopt($sock, IPPROTO_TCP, $var)) or die;
593         is(unpack('i', $x), $defer_accept_val,
594                 'TCP_DEFER_ACCEPT unchanged if previously set');
595 };
596 SKIP: {
597         skip 'SO_ACCEPTFILTER is FreeBSD-only', 1 if $^O ne 'freebsd';
598         skip 'accf_data not loaded: kldload accf_data' if !defined $accf_arg;
599         my $var = $PublicInbox::Daemon::SO_ACCEPTFILTER;
600         defined(my $x = getsockopt($sock, SOL_SOCKET, $var)) or die;
601         is($x, $accf_arg, 'SO_ACCEPTFILTER unchanged if previously set');
602 };
603
604 SKIP: {
605         skip 'only testing lsof(8) output on Linux', 1 if $^O ne 'linux';
606         my $lsof = require_cmd('lsof', 1) or skip 'no lsof in PATH', 1;
607         my $null_in = '';
608         my $rdr = { 2 => \(my $null_err), 0 => \$null_in };
609         my @lsof = xqx([$lsof, '-p', $td->{pid}], undef, $rdr);
610         my $d = [ grep(/\(deleted\)/, @lsof) ];
611         is_deeply($d, [], 'no lingering deleted inputs') or diag explain($d);
612
613         # filter out pipes inherited from the parent
614         my @this = xqx([$lsof, '-p', $$], undef, $rdr);
615         my $bad;
616         my $extract_inodes = sub {
617                 map {;
618                         my @f = split(' ', $_);
619                         my $inode = $f[-2];
620                         $bad = $_ if $inode !~ /\A[0-9]+\z/;
621                         $inode => 1;
622                 } grep (/\bpipe\b/, @_);
623         };
624         my %child = $extract_inodes->(@lsof);
625         my %parent = $extract_inodes->(@this);
626         skip("inode not in expected format: $bad", 1) if defined($bad);
627         delete @child{(keys %parent)};
628         is_deeply([], [keys %child], 'no extra pipes with -W0');
629 };
630
631 # ensure compatibility with other PSGI servers
632 SKIP: {
633         require_mods(@zmods, qw(Plack::Test HTTP::Request::Common), 3);
634         use_ok 'HTTP::Request::Common';
635         use_ok 'Plack::Test';
636         STDERR->flush;
637         open my $olderr, '>&', \*STDERR or die "dup stderr: $!";
638         open my $tmperr, '+>', undef or die;
639         open STDERR, '>&', $tmperr or die;
640         STDERR->autoflush(1);
641         my $app = require $psgi;
642         test_psgi($app, sub {
643                 my ($cb) = @_;
644                 my $req = GET('http://example.com/psgi-return-gzip');
645                 my $res = $cb->($req);
646                 my $buf = $res->content;
647                 IO::Uncompress::Gunzip::gunzip(\$buf => \(my $out));
648                 is($out, "hello world\n", 'got expected output');
649
650                 $req = GET('http://example.com/psgi-return-enoent');
651                 $res = $cb->($req);
652                 is($res->code, 500, 'got error on ENOENT');
653                 seek($tmperr, 0, SEEK_SET) or die;
654                 my $errbuf = do { local $/; <$tmperr> };
655                 like($errbuf, qr/this-better-not-exist/,
656                         'error logged about missing command');
657         });
658         open STDERR, '>&', $olderr or die "restore stderr: $!";
659 }
660
661 done_testing();
662
663 sub capture {
664         my ($f) = @_;
665         open my $fh, '+<', $f or die "failed to open $f: $!\n";
666         local $/ = "\n";
667         my @r = <$fh>;
668         truncate($fh, 0) or die "truncate failed on $f: $!\n";
669         \@r
670 }
671
672 1;