about summary refs log tree commit
diff options
context:
space:
mode:
-rw-r--r--t/common.perl174
-rw-r--r--t/git-http-backend.t14
-rw-r--r--t/httpd-corner.t48
-rw-r--r--t/httpd-https.t28
-rw-r--r--t/httpd-unix.t47
-rw-r--r--t/httpd.t13
-rw-r--r--t/nntpd-tls.t29
-rw-r--r--t/nntpd-validate.t27
-rw-r--r--t/nntpd.t16
-rw-r--r--t/perf-nntpd.t22
-rw-r--r--t/v2mirror.t21
-rw-r--r--t/v2writable.t8
-rw-r--r--t/www_listing.t8
13 files changed, 234 insertions, 221 deletions
diff --git a/t/common.perl b/t/common.perl
index c5693080..2126a761 100644
--- a/t/common.perl
+++ b/t/common.perl
@@ -30,30 +30,6 @@ sub tcp_connect {
         $s;
 }
 
-sub spawn_listener {
-        my ($env, $cmd, $socks) = @_;
-        my $pid = fork;
-        defined $pid or die "fork failed: $!\n";
-        if ($pid == 0) {
-                # pretend to be systemd (cf. sd_listen_fds(3))
-                my $fd = 3; # 3 == SD_LISTEN_FDS_START
-                foreach my $s (@$socks) {
-                        my $fl = fcntl($s, F_GETFD, 0);
-                        if (($fl & FD_CLOEXEC) != FD_CLOEXEC) {
-                                warn "got FD:".fileno($s)." w/o CLOEXEC\n";
-                        }
-                        fcntl($s, F_SETFD, $fl &= ~FD_CLOEXEC);
-                        dup2(fileno($s), $fd++) or die "dup2 failed: $!\n";
-                }
-                $ENV{LISTEN_PID} = $$;
-                $ENV{LISTEN_FDS} = scalar @$socks;
-                %ENV = (%ENV, %$env) if $env;
-                exec @$cmd;
-                die "FAIL: ",join(' ', @$cmd), ": $!\n";
-        }
-        $pid;
-}
-
 sub require_git ($;$) {
         my ($req, $maybe) = @_;
         my ($req_maj, $req_min) = split(/\./, $req);
@@ -68,7 +44,6 @@ sub require_git ($;$) {
         1;
 }
 
-my %cached_scripts;
 sub key2script ($) {
         my ($key) = @_;
         return $key if $key =~ m!\A/!;
@@ -105,11 +80,10 @@ sub run_script_exit (;$) {
         die RUN_SCRIPT_EXIT;
 }
 
-sub run_script ($;$$) {
-        my ($cmd, $env, $opt) = @_;
-        my ($key, @argv) = @$cmd;
-        my $run_mode = $ENV{TEST_RUN_MODE} // $opt->{run_mode} // 1;
-        my $sub = $run_mode == 0 ? undef : ($cached_scripts{$key} //= do {
+my %cached_scripts;
+sub key2sub ($) {
+        my ($key) = @_;
+        $cached_scripts{$key} //= do {
                 my $f = key2script($key);
                 open my $fh, '<', $f or die "open $f: $!";
                 my $str = do { local $/; <$fh> };
@@ -129,8 +103,34 @@ $str
 1;
 EOF
                 $pkg->can('main');
-        }); # do
+        }
+}
 
+sub _run_sub ($$$) {
+        my ($sub, $key, $argv) = @_;
+        local @ARGV = @$argv;
+        $run_script_exit_code = undef;
+        my $exit_code = eval { $sub->(@$argv) };
+        if ($@ eq RUN_SCRIPT_EXIT) {
+                $@ = '';
+                $exit_code = $run_script_exit_code;
+                $? = ($exit_code << 8);
+        } elsif (defined($exit_code)) {
+                $? = ($exit_code << 8);
+        } elsif ($@) { # mimic die() behavior when uncaught
+                warn "E: eval-ed $key: $@\n";
+                $? = ($! << 8) if $!;
+                $? = (255 << 8) if $? == 0;
+        } else {
+                die "BUG: eval-ed $key: no exit code or \$@\n";
+        }
+}
+
+sub run_script ($;$$) {
+        my ($cmd, $env, $opt) = @_;
+        my ($key, @argv) = @$cmd;
+        my $run_mode = $ENV{TEST_RUN_MODE} // $opt->{run_mode} // 1;
+        my $sub = $run_mode == 0 ? undef : key2sub($key);
         my $fhref = [];
         my $spawn_opt = {};
         for my $fd (0..2) {
@@ -162,22 +162,7 @@ EOF
                 local %ENV = $env ? (%ENV, %$env) : %ENV;
                 local %SIG = %SIG;
                 _prepare_redirects($fhref);
-                local @ARGV = @argv;
-                $run_script_exit_code = undef;
-                my $exit_code = eval { $sub->(@argv) };
-                if ($@ eq RUN_SCRIPT_EXIT) {
-                        $@ = '';
-                        $exit_code = $run_script_exit_code;
-                        $? = ($exit_code << 8);
-                } elsif (defined($exit_code)) {
-                        $? = ($exit_code << 8);
-                } elsif ($@) { # mimic die() behavior when uncaught
-                        warn "E: eval-ed $key: $@\n";
-                        $? = ($! << 8) if $!;
-                        $? = (255 << 8) if $? == 0;
-                } else {
-                        die "BUG: eval-ed $key: no exit code or \$@\n";
-                }
+                _run_sub($sub, $key, \@argv);
         }
 
         # slurp the redirects back into user-supplied strings
@@ -191,4 +176,99 @@ EOF
         $? == 0;
 }
 
+sub wait_for_tail () { sleep(2) }
+
+sub start_script {
+        my ($cmd, $env, $opt) = @_;
+        my ($key, @argv) = @$cmd;
+        my $run_mode = $ENV{TEST_RUN_MODE} // $opt->{run_mode} // 1;
+        my $sub = $run_mode == 0 ? undef : key2sub($key);
+        my $tail_pid;
+        if (my $tail_cmd = $ENV{TAIL}) {
+                my @paths;
+                for (@argv) {
+                        next unless /\A--std(?:err|out)=(.+)\z/;
+                        push @paths, $1;
+                }
+                if (@paths) {
+                        defined($tail_pid = fork) or die "fork: $!\n";
+                        if ($tail_pid == 0) {
+                                # make sure files exist, first
+                                open my $fh, '>>', $_ for @paths;
+                                open(STDOUT, '>&STDERR') or die "1>&2: $!";
+                                exec(split(' ', $tail_cmd), @paths);
+                                die "$tail_cmd failed: $!";
+                        }
+                        wait_for_tail();
+                }
+        }
+        defined(my $pid = fork) or die "fork: $!\n";
+        if ($pid == 0) {
+                # pretend to be systemd (cf. sd_listen_fds(3))
+                # 3 == SD_LISTEN_FDS_START
+                my $fd;
+                for ($fd = 0; 1; $fd++) {
+                        my $s = $opt->{$fd};
+                        last if $fd >= 3 && !defined($s);
+                        next unless $s;
+                        my $fl = fcntl($s, F_GETFD, 0);
+                        if (($fl & FD_CLOEXEC) != FD_CLOEXEC) {
+                                warn "got FD:".fileno($s)." w/o CLOEXEC\n";
+                        }
+                        fcntl($s, F_SETFD, $fl &= ~FD_CLOEXEC);
+                        dup2(fileno($s), $fd) or die "dup2 failed: $!\n";
+                }
+                %ENV = (%ENV, %$env) if $env;
+                my $fds = $fd - 3;
+                if ($fds > 0) {
+                        $ENV{LISTEN_PID} = $$;
+                        $ENV{LISTEN_FDS} = $fds;
+                }
+                $0 = join(' ', @$cmd);
+                if ($sub) {
+                        _run_sub($sub, $key, \@argv);
+                        POSIX::_exit($? >> 8);
+                } else {
+                        exec(key2script($key), @argv);
+                        die "FAIL: ",join(' ', $key, @argv), ": $!\n";
+                }
+        }
+        TestProcess->new($pid, $tail_pid);
+}
+
+package TestProcess;
+use strict;
+
+# prevent new threads from inheriting these objects
+sub CLONE_SKIP { 1 }
+
+sub new {
+        my ($klass, $pid, $tail_pid) = @_;
+        bless { pid => $pid, tail_pid => $tail_pid, owner => $$ }, $klass;
+}
+
+sub kill {
+        my ($self, $sig) = @_;
+        CORE::kill($sig // 'TERM', $self->{pid});
+}
+
+sub join {
+        my ($self) = @_;
+        my $pid = delete $self->{pid} or return;
+        my $ret = waitpid($pid, 0);
+        defined($ret) or die "waitpid($pid): $!";
+        $ret == $pid or die "waitpid($pid) != $ret";
+}
+
+sub DESTROY {
+        my ($self) = @_;
+        return if $self->{owner} != $$;
+        if (my $tail = delete $self->{tail_pid}) {
+                ::wait_for_tail();
+                CORE::kill('TERM', $tail);
+        }
+        my $pid = delete $self->{pid} or return;
+        CORE::kill('TERM', $pid);
+}
+
 1;
diff --git a/t/git-http-backend.t b/t/git-http-backend.t
index c2a04653..c4dc09a1 100644
--- a/t/git-http-backend.t
+++ b/t/git-http-backend.t
@@ -22,12 +22,10 @@ my $psgi = "./t/git-http-backend.psgi";
 my $tmpdir = tempdir('pi-git-http-backend-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 my $err = "$tmpdir/stderr.log";
 my $out = "$tmpdir/stdout.log";
-my $httpd = 'blib/script/public-inbox-httpd';
 my $sock = tcp_server();
 my $host = $sock->sockhost;
 my $port = $sock->sockport;
-my $pid;
-END { kill 'TERM', $pid if defined $pid };
+my $td;
 
 my $get_maxrss = sub {
         my $http = Net::HTTP->new(Host => "$host:$port");
@@ -44,9 +42,8 @@ my $get_maxrss = sub {
 
 {
         ok($sock, 'sock created');
-        my $cmd = [ $httpd, '-W0', "--stdout=$out", "--stderr=$err", $psgi ];
-        ok(defined($pid = spawn_listener(undef, $cmd, [$sock])),
-           'forked httpd process successfully');
+        my $cmd = [ '-httpd', '-W0', "--stdout=$out", "--stderr=$err", $psgi ];
+        $td = start_script($cmd, undef, { 3 => $sock });
 }
 my $mem_a = $get_maxrss->();
 
@@ -113,9 +110,8 @@ SKIP: {
 }
 
 {
-        ok(kill('TERM', $pid), 'killed httpd');
-        $pid = undef;
-        waitpid(-1, 0);
+        ok($td->kill, 'killed httpd');
+        $td->join;
 }
 
 done_testing();
diff --git a/t/httpd-corner.t b/t/httpd-corner.t
index cc36c7e1..eca77d7f 100644
--- a/t/httpd-corner.t
+++ b/t/httpd-corner.t
@@ -26,7 +26,6 @@ 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 = "./t/httpd-corner.psgi";
 my $sock = tcp_server() or die;
 
@@ -64,13 +63,11 @@ sub unix_server ($) {
 my $upath = "$tmpdir/s";
 my $unix = unix_server($upath);
 ok($unix, 'UNIX socket created');
-my $pid;
-END { kill 'TERM', $pid if defined $pid };
+my $td;
 my $spawn_httpd = sub {
         my (@args) = @_;
-        my $cmd = [ $httpd, @args, "--stdout=$out", "--stderr=$err", $psgi ];
-        $pid = spawn_listener(undef, $cmd, [ $sock, $unix ]);
-        ok(defined $pid, 'forked httpd process successfully');
+        my $cmd = [ '-httpd', @args, "--stdout=$out", "--stderr=$err", $psgi ];
+        $td = start_script($cmd, undef, { 3 => $sock, 4 => $unix });
 };
 
 $spawn_httpd->();
@@ -213,16 +210,14 @@ sub conn_for {
         open my $f, '>', $fifo or die "open $fifo: $!\n";
         $f->autoflush(1);
         ok(print($f "hello\n"), 'wrote something to fifo');
-        my $kpid = $pid;
-        $pid = undef;
-        is(kill('TERM', $kpid), 1, 'started graceful shutdown');
+        is($td->kill, 1, 'started graceful shutdown');
         ok(print($f "world\n"), 'wrote else to fifo');
         close $f or die "close fifo: $!\n";
         $conn->read(my $buf, 8192);
         my ($head, $body) = split(/\r\n\r\n/, $buf, 2);
         like($head, qr!\AHTTP/1\.[01] 200 OK!, 'got 200 for slow-header');
         is($body, "hello\nworld\n", 'read expected body');
-        is(waitpid($kpid, 0), $kpid, 'reaped httpd');
+        $td->join;
         is($?, 0, 'no error');
         $spawn_httpd->('-W0');
 }
@@ -244,15 +239,13 @@ sub conn_for {
                 $conn->sysread($buf, 8192);
                 is($buf, $c, 'got trickle for reading');
         }
-        my $kpid = $pid;
-        $pid = undef;
-        is(kill('TERM', $kpid), 1, 'started graceful shutdown');
+        is($td->kill, 1, 'started graceful shutdown');
         ok(print($f "world\n"), 'wrote else to fifo');
         close $f or die "close fifo: $!\n";
         $conn->sysread($buf, 8192);
         is($buf, "world\n", 'read expected body');
         is($conn->sysread($buf, 8192), 0, 'got EOF from server');
-        is(waitpid($kpid, 0), $kpid, 'reaped httpd');
+        $td->join;
         is($?, 0, 'no error');
         $spawn_httpd->('-W0');
 }
@@ -346,9 +339,7 @@ SKIP: {
         $conn->write("Content-Length: $len\r\n");
         delay();
         $conn->write("\r\n");
-        my $kpid = $pid;
-        $pid = undef;
-        is(kill('TERM', $kpid), 1, 'started graceful shutdown');
+        is($td->kill, 1, 'started graceful shutdown');
         delay();
         my $n = 0;
         foreach my $c ('a'..'z') {
@@ -356,7 +347,7 @@ SKIP: {
         }
         is($n, $len, 'wrote alphabet');
         $check_self->($conn);
-        is(waitpid($kpid, 0), $kpid, 'reaped httpd');
+        $td->join;
         is($?, 0, 'no error');
         $spawn_httpd->('-W0');
 }
@@ -553,12 +544,29 @@ SKIP: {
         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`;
+        my @lsof = `lsof -p $td->{pid}`;
         is_deeply([grep(/\bdeleted\b/, @lsof)], [], 'no lingering deleted inputs');
-        is_deeply([grep(/\bpipe\b/, @lsof)], [], 'no extra pipes with -W0');
+
+        # filter out pipes inherited from the parent
+        my @this = `lsof -p $$`;
+        my $bad;
+        sub extract_inodes {
+                map {;
+                        my @f = split(' ', $_);
+                        my $inode = $f[-2];
+                        $bad = $_ if $inode !~ /\A[0-9]+\z/;
+                        $inode => 1;
+                } grep (/\bpipe\b/, @_);
+        }
+        my %child = extract_inodes(@lsof);
+        my %parent = extract_inodes(@this);
+        skip("inode not in expected format: $bad", 1) if defined($bad);
+        delete @child{(keys %parent)};
+        is_deeply([], [keys %child], 'no extra pipes with -W0');
 };
 
 done_testing();
diff --git a/t/httpd-https.t b/t/httpd-https.t
index 22c62bf4..81a11108 100644
--- a/t/httpd-https.t
+++ b/t/httpd-https.t
@@ -23,14 +23,8 @@ my $psgi = "./t/httpd-corner.psgi";
 my $tmpdir = tempdir('pi-httpd-https-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 my $err = "$tmpdir/stderr.log";
 my $out = "$tmpdir/stdout.log";
-my $httpd = 'blib/script/public-inbox-httpd';
 my $https = tcp_server();
-my ($pid, $tail_pid);
-END {
-        foreach ($pid, $tail_pid) {
-                kill 'TERM', $_ if defined $_;
-        }
-};
+my $td;
 my $https_addr = $https->sockhost . ':' . $https->sockport;
 
 for my $args (
@@ -39,15 +33,9 @@ for my $args (
         for ($out, $err) {
                 open my $fh, '>', $_ or die "truncate: $!";
         }
-        if (my $tail_cmd = $ENV{TAIL}) { # don't assume GNU tail
-                $tail_pid = fork;
-                if (defined $tail_pid && $tail_pid == 0) {
-                        exec(split(' ', $tail_cmd), $out, $err);
-                }
-        }
-        my $cmd = [ $httpd, '-W0', @$args,
+        my $cmd = [ '-httpd', '-W0', @$args,
                         "--stdout=$out", "--stderr=$err", $psgi ];
-        $pid = spawn_listener(undef, $cmd, [ $https ]);
+        $td = start_script($cmd, undef, { 3 => $https });
         my %o = (
                 SSL_hostname => 'server.local',
                 SSL_verifycn_name => 'server.local',
@@ -119,15 +107,9 @@ for my $args (
         };
 
         $c = undef;
-        kill('TERM', $pid);
-        is($pid, waitpid($pid, 0), 'httpd exited successfully');
+        $td->kill;
+        $td->join;
         is($?, 0, 'no error in exited process');
-        $pid = undef;
-        if (defined $tail_pid) {
-                kill 'TERM', $tail_pid;
-                waitpid($tail_pid, 0);
-                $tail_pid = undef;
-        }
 }
 done_testing();
 1;
diff --git a/t/httpd-unix.t b/t/httpd-unix.t
index 2c918281..5ec70fd8 100644
--- a/t/httpd-unix.t
+++ b/t/httpd-unix.t
@@ -4,6 +4,8 @@
 use strict;
 use warnings;
 use Test::More;
+require './t/common.perl';
+use Errno qw(EADDRINUSE);
 
 foreach my $mod (qw(Plack::Util Plack::Builder HTTP::Date HTTP::Status)) {
         eval "require $mod";
@@ -14,23 +16,16 @@ use File::Temp qw/tempdir/;
 use IO::Socket::UNIX;
 my $tmpdir = tempdir('httpd-unix-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 my $unix = "$tmpdir/unix.sock";
-my $httpd = 'blib/script/public-inbox-httpd';
 my $psgi = './t/httpd-corner.psgi';
 my $out = "$tmpdir/out.log";
 my $err = "$tmpdir/err.log";
-
-my $pid;
-END { kill 'TERM', $pid if defined $pid };
+my $td;
 
 my $spawn_httpd = sub {
         my (@args) = @_;
         push @args, '-W0';
-        $pid = fork;
-        if ($pid == 0) {
-                exec $httpd, @args, "--stdout=$out", "--stderr=$err", $psgi;
-                die "FAIL: $!\n";
-        }
-        ok(defined $pid, 'forked httpd process successfully');
+        my $cmd = [ '-httpd', @args, "--stdout=$out", "--stderr=$err", $psgi ];
+        $td = start_script($cmd);
 };
 
 {
@@ -65,15 +60,18 @@ sub check_sock ($) {
 check_sock($unix);
 
 { # do not clobber existing socket
-        my $fpid = fork;
-        if ($fpid == 0) {
-                open STDOUT, '>>', "$tmpdir/1" or die "redirect failed: $!";
-                open STDERR, '>>', "$tmpdir/2" or die "redirect failed: $!";
-                exec $httpd, '-l', $unix, '-W0', $psgi;
-                die "FAIL: $!\n";
-        }
-        is($fpid, waitpid($fpid, 0), 'second httpd exits');
-        isnt($?, 0, 'httpd failed with failure to bind');
+        my %err = ( 'linux' => EADDRINUSE );
+        open my $out, '>>', "$tmpdir/1" or die "redirect failed: $!";
+        open my $err, '>>', "$tmpdir/2" or die "redirect failed: $!";
+        my $cmd = ['-httpd', '-l', $unix, '-W0', $psgi];
+        my $ftd = start_script($cmd, undef, { 1 => $out, 2 => $err });
+        $ftd->join;
+        isnt($?, 0, 'httpd failure set $?');
+        SKIP: {
+                my $ec = $err{$^O} or
+                        skip("not sure if $^O fails with EADDRINUSE", 1);
+                is($? >> 8, $ec, 'httpd failed with EADDRINUSE');
+        };
         open my $fh, "$tmpdir/2" or die "failed to open $tmpdir/2: $!";
         local $/;
         my $e = <$fh>;
@@ -82,10 +80,8 @@ check_sock($unix);
 }
 
 {
-        my $kpid = $pid;
-        $pid = undef;
-        is(kill('TERM', $kpid), 1, 'terminate existing process');
-        is(waitpid($kpid, 0), $kpid, 'existing httpd terminated');
+        is($td->kill, 1, 'terminate existing process');
+        $td->join;
         is($?, 0, 'existing httpd exited successfully');
         ok(-S $unix, 'unix socket still exists');
 }
@@ -96,9 +92,8 @@ SKIP: {
 
         # wait for daemonization
         $spawn_httpd->("-l$unix", '-D', '-P', "$tmpdir/pid");
-        my $kpid = $pid;
-        $pid = undef;
-        is(waitpid($kpid, 0), $kpid, 'existing httpd terminated');
+        $td->join;
+        is($?, 0, 'daemonized process OK');
         check_sock($unix);
 
         ok(-f "$tmpdir/pid", 'pid file written');
diff --git a/t/httpd.t b/t/httpd.t
index e7527ed6..ce8063b2 100644
--- a/t/httpd.t
+++ b/t/httpd.t
@@ -21,13 +21,11 @@ my $maindir = "$tmpdir/main.git";
 my $group = 'test-httpd';
 my $addr = $group . '@example.com';
 my $cfgpfx = "publicinbox.$group";
-my $httpd = 'blib/script/public-inbox-httpd';
 my $sock = tcp_server();
-my $pid;
+my $td;
 use_ok 'PublicInbox::Git';
 use_ok 'PublicInbox::Import';
 use_ok 'Email::MIME';
-END { kill 'TERM', $pid if defined $pid };
 {
         local $ENV{HOME} = $home;
         my $cmd = [ '-init', $group, $maindir, 'http://example.com/', $addr ];
@@ -52,8 +50,8 @@ EOF
                 $im->done($mime);
         }
         ok($sock, 'sock created');
-        $cmd = [ $httpd, '-W0', "--stdout=$out", "--stderr=$err" ];
-        $pid = spawn_listener(undef, $cmd, [$sock]);
+        $cmd = [ '-httpd', '-W0', "--stdout=$out", "--stderr=$err" ];
+        $td = start_script($cmd, undef, { 3 => $sock });
         my $host = $sock->sockhost;
         my $port = $sock->sockport;
         my $conn = tcp_connect($sock);
@@ -78,9 +76,8 @@ EOF
                         "http://$host:$port/$group", "$tmpdir/dumb.git"),
                 0, 'clone successful');
 
-        ok(kill('TERM', $pid), 'killed httpd');
-        $pid = undef;
-        waitpid(-1, 0);
+        ok($td->kill, 'killed httpd');
+        $td->join;
 
         is(system('git', "--git-dir=$tmpdir/clone.git",
                   qw(fsck --no-verbose)), 0,
diff --git a/t/nntpd-tls.t b/t/nntpd-tls.t
index 4e71e82d..5d170b78 100644
--- a/t/nntpd-tls.t
+++ b/t/nntpd-tls.t
@@ -41,16 +41,8 @@ my $inboxdir = "$tmpdir";
 my $pi_config = "$tmpdir/pi_config";
 my $group = 'test-nntpd-tls';
 my $addr = $group . '@example.com';
-my $nntpd = 'blib/script/public-inbox-nntpd';
 my $starttls = tcp_server();
 my $nntps = tcp_server();
-my ($pid, $tail_pid);
-END {
-        foreach ($pid, $tail_pid) {
-                kill 'TERM', $_ if defined $_;
-        }
-};
-
 my $ibx = PublicInbox::Inbox->new({
         inboxdir => $inboxdir,
         name => 'nntpd-tls',
@@ -91,6 +83,7 @@ EOF
 my $nntps_addr = $nntps->sockhost . ':' . $nntps->sockport;
 my $starttls_addr = $starttls->sockhost . ':' . $starttls->sockport;
 my $env = { PI_CONFIG => $pi_config };
+my $td;
 
 for my $args (
         [ "--cert=$cert", "--key=$key",
@@ -100,14 +93,8 @@ for my $args (
         for ($out, $err) {
                 open my $fh, '>', $_ or die "truncate: $!";
         }
-        if (my $tail_cmd = $ENV{TAIL}) { # don't assume GNU tail
-                $tail_pid = fork;
-                if (defined $tail_pid && $tail_pid == 0) {
-                        exec(split(' ', $tail_cmd), $out, $err);
-                }
-        }
-        my $cmd = [ $nntpd, '-W0', @$args, "--stdout=$out", "--stderr=$err" ];
-        $pid = spawn_listener($env, $cmd, [ $starttls, $nntps ]);
+        my $cmd = [ '-nntpd', '-W0', @$args, "--stdout=$out", "--stderr=$err" ];
+        $td = start_script($cmd, $env, { 3 => $starttls, 4 => $nntps });
         my %o = (
                 SSL_hostname => 'server.local',
                 SSL_verifycn_name => 'server.local',
@@ -211,21 +198,15 @@ for my $args (
         };
 
         $c = undef;
-        kill('TERM', $pid);
-        is($pid, waitpid($pid, 0), 'nntpd exited successfully');
+        $td->kill;
+        $td->join;
         is($?, 0, 'no error in exited process');
-        $pid = undef;
         my $eout = eval {
                 open my $fh, '<', $err or die "open $err failed: $!";
                 local $/;
                 <$fh>;
         };
         unlike($eout, qr/wide/i, 'no Wide character warnings');
-        if (defined $tail_pid) {
-                kill 'TERM', $tail_pid;
-                waitpid($tail_pid, 0);
-                $tail_pid = undef;
-        }
 }
 done_testing();
 
diff --git a/t/nntpd-validate.t b/t/nntpd-validate.t
index de024394..e3c10d9c 100644
--- a/t/nntpd-validate.t
+++ b/t/nntpd-validate.t
@@ -10,9 +10,15 @@ use Symbol qw(gensym);
 use Time::HiRes qw(clock_gettime CLOCK_MONOTONIC);
 my $inbox_dir = $ENV{GIANT_INBOX_DIR};
 plan skip_all => "GIANT_INBOX_DIR not defined for $0" unless $inbox_dir;
+if (my $m = $ENV{TEST_RUN_MODE}) {
+        plan skip_all => "threads conflict w/ TEST_RUN_MODE=$m";
+}
 my $mid = $ENV{TEST_MID};
 
 # This test is also an excuse for me to experiment with Perl threads :P
+# TODO: get rid of threads, I was reading an old threads(3perl) manpage
+# and missed the WARNING in the newer ones about it being "discouraged"
+# in perlpolicy(1).
 unless (eval 'use threads; 1') {
         plan skip_all => "$0 requires a threaded perl" if $@;
 }
@@ -37,13 +43,8 @@ if ($test_tls && !-r $key || !-r $cert) {
 require './t/common.perl';
 my $keep_tmp = !!$ENV{TEST_KEEP_TMP};
 my $tmpdir = tempdir('nntpd-validate-XXXXXX',TMPDIR => 1,CLEANUP => $keep_tmp);
-my (%OPT, $pid, $tail_pid, $host_port, $group);
+my (%OPT, $td, $host_port, $group);
 my $batch = 1000;
-END {
-        foreach ($pid, $tail_pid) {
-                kill 'TERM', $_ if defined $_;
-        }
-};
 if (($ENV{NNTP_TEST_URL} // '') =~ m!\Anntp://([^/]+)/([^/]+)\z!) {
         ($host_port, $group) = ($1, $2);
         $host_port .= ":119" unless index($host_port, ':') > 0;
@@ -149,7 +150,6 @@ sub make_local_server {
         $group = 'inbox.test.perf.nntpd';
         my $ibx = { inboxdir => $inbox_dir, newsgroup => $group };
         $ibx = PublicInbox::Inbox->new($ibx);
-        my $nntpd = 'blib/script/public-inbox-nntpd';
         my $pi_config = "$tmpdir/config";
         {
                 open my $fh, '>', $pi_config or die "open($pi_config): $!";
@@ -165,20 +165,13 @@ sub make_local_server {
         for ($out, $err) {
                 open my $fh, '>', $_ or die "truncate: $!";
         }
-        if (my $tail_cmd = $ENV{TAIL}) { # don't assume GNU tail
-                $tail_pid = fork;
-                if (defined $tail_pid && $tail_pid == 0) {
-                        open STDOUT, '>&STDERR' or die ">&2 failed: $!";
-                        exec(split(' ', $tail_cmd), $out, $err);
-                }
-        }
         my $sock = tcp_server();
         ok($sock, 'sock created');
         $host_port = $sock->sockhost . ':' . $sock->sockport;
 
         # not using multiple workers, here, since we want to increase
         # the chance of tripping concurrency bugs within PublicInbox/NNTP*.pm
-        my $cmd = [ $nntpd, "--stdout=$out", "--stderr=$err", '-W0' ];
+        my $cmd = [ '-nntpd', "--stdout=$out", "--stderr=$err", '-W0' ];
         push @$cmd, "-lnntp://$host_port";
         if ($test_tls) {
                 push @$cmd, "--cert=$cert", "--key=$key";
@@ -190,7 +183,9 @@ sub make_local_server {
                 );
         }
         print STDERR "# CMD ". join(' ', @$cmd). "\n";
-        $pid = spawn_listener({ PI_CONFIG => $pi_config }, $cmd, [$sock]);
+        my $env = { PI_CONFIG => $pi_config };
+        # perl threads and run_mode != 0 don't get along
+        $td = start_script($cmd, $env, { run_mode => 0, 3 => $sock });
 }
 
 package DigestPipe;
diff --git a/t/nntpd.t b/t/nntpd.t
index 4795dc00..3c928610 100644
--- a/t/nntpd.t
+++ b/t/nntpd.t
@@ -29,7 +29,6 @@ my $out = "$tmpdir/stdout.log";
 my $inboxdir = "$tmpdir/main.git";
 my $group = 'test-nntpd';
 my $addr = $group . '@example.com';
-my $nntpd = 'blib/script/public-inbox-nntpd';
 SKIP: {
         skip "git 2.6+ required for V2Writable", 1 if $version == 1;
         use_ok 'PublicInbox::V2Writable';
@@ -37,9 +36,8 @@ SKIP: {
 
 my %opts;
 my $sock = tcp_server();
-my $pid;
+my $td;
 my $len;
-END { kill 'TERM', $pid if defined $pid };
 
 my $ibx = {
         inboxdir => $inboxdir,
@@ -90,9 +88,8 @@ EOF
         }
 
         ok($sock, 'sock created');
-        my $cmd = [ $nntpd, '-W0', "--stdout=$out", "--stderr=$err" ];
-        $pid = spawn_listener(undef, $cmd, [ $sock ]);
-        ok(defined $pid, 'forked nntpd process successfully');
+        my $cmd = [ '-nntpd', '-W0', "--stdout=$out", "--stderr=$err" ];
+        $td = start_script($cmd, undef, { 3 => $sock });
         my $host_port = $sock->sockhost . ':' . $sock->sockport;
         my $n = Net::NNTP->new($host_port);
         my $list = $n->list;
@@ -306,7 +303,7 @@ Date: Fri, 02 Oct 1993 00:00:00 +0000
                 is($? >> 8, 0, 'no errors');
         }
         SKIP: {
-                my @of = `lsof -p $pid 2>/dev/null`;
+                my @of = `lsof -p $td->{pid} 2>/dev/null`;
                 skip('lsof broken', 1) if (!scalar(@of) || $?);
                 my @xap = grep m!Search/Xapian!, @of;
                 is_deeply(\@xap, [], 'Xapian not loaded in nntpd');
@@ -315,7 +312,7 @@ Date: Fri, 02 Oct 1993 00:00:00 +0000
                 setsockopt($s, IPPROTO_TCP, TCP_NODELAY, 1);
                 syswrite($s, 'HDR List-id 1-');
                 select(undef, undef, undef, 0.15);
-                ok(kill('TERM', $pid), 'killed nntpd');
+                ok($td->kill, 'killed nntpd');
                 select(undef, undef, undef, 0.15);
                 syswrite($s, "\r\n");
                 $buf = '';
@@ -329,7 +326,7 @@ Date: Fri, 02 Oct 1993 00:00:00 +0000
         }
 
         $n = $s = undef;
-        is($pid, waitpid($pid, 0), 'nntpd exited successfully');
+        $td->join;
         my $eout = eval {
                 local $/;
                 open my $fh, '<', $err or die "open $err failed: $!";
@@ -339,6 +336,7 @@ Date: Fri, 02 Oct 1993 00:00:00 +0000
         unlike($eout, qr/wide/i, 'no Wide character warnings');
 }
 
+$td = undef;
 done_testing();
 
 sub read_til_dot {
diff --git a/t/perf-nntpd.t b/t/perf-nntpd.t
index 7abf2249..c7d2eaff 100644
--- a/t/perf-nntpd.t
+++ b/t/perf-nntpd.t
@@ -10,18 +10,9 @@ use Net::NNTP;
 my $pi_dir = $ENV{GIANT_PI_DIR};
 plan skip_all => "GIANT_PI_DIR not defined for $0" unless $pi_dir;
 eval { require PublicInbox::Search };
-my ($host_port, $group, %opts, $s, $pid);
+my ($host_port, $group, %opts, $s, $td);
 require './t/common.perl';
 
-END {
-        if ($s) {
-                $s->print("QUIT\r\n");
-                $s->getline;
-                $s = undef;
-        }
-        kill 'TERM', $pid if defined $pid;
-};
-
 if (($ENV{NNTP_TEST_URL} || '') =~ m!\Anntp://([^/]+)/([^/]+)\z!) {
         ($host_port, $group) = ($1, $2);
         $host_port .= ":119" unless index($host_port, ':') > 0;
@@ -29,7 +20,6 @@ if (($ENV{NNTP_TEST_URL} || '') =~ m!\Anntp://([^/]+)/([^/]+)\z!) {
         $group = 'inbox.test.perf.nntpd';
         my $ibx = { inboxdir => $pi_dir, newsgroup => $group };
         $ibx = PublicInbox::Inbox->new($ibx);
-        my $nntpd = 'blib/script/public-inbox-nntpd';
         my $tmpdir = tempdir('perf-nntpd-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 
         my $pi_config = "$tmpdir/config";
@@ -46,8 +36,8 @@ if (($ENV{NNTP_TEST_URL} || '') =~ m!\Anntp://([^/]+)/([^/]+)\z!) {
 
         my $sock = tcp_server();
         ok($sock, 'sock created');
-        my $cmd = [ $nntpd, '-W0' ];
-        $pid = spawn_listener({ PI_CONFIG => $pi_config }, $cmd, [$sock]);
+        my $cmd = [ '-nntpd', '-W0' ];
+        $td = start_script($cmd, { PI_CONFIG => $pi_config }, { 3 => $sock });
         $host_port = $sock->sockhost . ':' . $sock->sockport;
 }
 %opts = (
@@ -110,6 +100,12 @@ $t = timeit(1, sub {
 });
 diag 'newnews took: ' . timestr($t) . " for $n";
 
+if ($s) {
+        $s->print("QUIT\r\n");
+        $s->getline;
+}
+
+
 done_testing();
 
 1;
diff --git a/t/v2mirror.t b/t/v2mirror.t
index 2c7f6a84..1a39ce49 100644
--- a/t/v2mirror.t
+++ b/t/v2mirror.t
@@ -21,7 +21,6 @@ use PublicInbox::MIME;
 use PublicInbox::Config;
 # FIXME: too much setup
 my $tmpdir = tempdir('pi-v2mirror-XXXXXX', TMPDIR => 1, CLEANUP => 1);
-my $script = 'blib/script/public-inbox';
 my $pi_config = "$tmpdir/config";
 {
         open my $fh, '>', $pi_config or die "open($pi_config): $!";
@@ -60,19 +59,10 @@ ok($epoch_max > 0, "multiple epochs");
 $v2w->done;
 $ibx->cleanup;
 
-my ($sock, $pid);
-
-# TODO: replace this with ->DESTROY:
-my $owner_pid = $$;
-END { kill('TERM', $pid) if defined($pid) && $owner_pid == $$ };
-
-$! = 0;
-$sock = tcp_server();
+my $sock = tcp_server();
 ok($sock, 'sock created');
-my $httpd = "$script-httpd";
-my $cmd = [ $httpd, '-W0', "--stdout=$tmpdir/out", "--stderr=$tmpdir/err" ];
-ok(defined($pid = spawn_listener(undef, $cmd, [ $sock ])),
-        'spawned httpd process successfully');
+my $cmd = [ '-httpd', '-W0', "--stdout=$tmpdir/out", "--stderr=$tmpdir/err" ];
+my $td = start_script($cmd, undef, { 3 => $sock });
 my ($host, $port) = ($sock->sockhost, $sock->sockport);
 $sock = undef;
 
@@ -194,9 +184,8 @@ is($mibx->git->check($to_purge), undef, 'unindex+prune successful in mirror');
         is(scalar($mset->items), 0, '1@example.com no longer visible in mirror');
 }
 
-ok(kill('TERM', $pid), 'killed httpd');
-$pid = undef;
-waitpid(-1, 0);
+ok($td->kill, 'killed httpd');
+$td->join;
 
 done_testing();
 
diff --git a/t/v2writable.t b/t/v2writable.t
index 28420bb9..4bb6d733 100644
--- a/t/v2writable.t
+++ b/t/v2writable.t
@@ -163,12 +163,10 @@ EOF
         close $fh or die "close: $!\n";
         my $sock = tcp_server();
         ok($sock, 'sock created');
-        my $pid;
         my $len;
-        END { kill 'TERM', $pid if defined $pid };
-        my $nntpd = 'blib/script/public-inbox-nntpd';
-        my $cmd = [ $nntpd, '-W0', "--stdout=$out", "--stderr=$err" ];
-        $pid = spawn_listener({ PI_CONFIG => $pi_config }, $cmd, [ $sock ]);
+        my $cmd = [ '-nntpd', '-W0', "--stdout=$out", "--stderr=$err" ];
+        my $env = { PI_CONFIG => $pi_config };
+        my $td = start_script($cmd, $env, { 3 => $sock });
         my $host_port = $sock->sockhost . ':' . $sock->sockport;
         my $n = Net::NNTP->new($host_port);
         $n->group($group);
diff --git a/t/www_listing.t b/t/www_listing.t
index 61a059e5..9cde3575 100644
--- a/t/www_listing.t
+++ b/t/www_listing.t
@@ -64,15 +64,13 @@ sub tiny_test {
                 'epoch 1 in description');
 }
 
-my $pid;
-END { kill 'TERM', $pid if defined $pid };
+my $td;
 SKIP: {
         my $err = "$tmpdir/stderr.log";
         my $out = "$tmpdir/stdout.log";
         my $alt = "$tmpdir/alt.git";
         my $cfgfile = "$tmpdir/config";
         my $v2 = "$tmpdir/v2";
-        my $httpd = 'blib/script/public-inbox-httpd';
         my $sock = tcp_server();
         ok($sock, 'sock created');
         my ($host, $port) = ($sock->sockhost, $sock->sockport);
@@ -106,8 +104,8 @@ SKIP: {
 
         close $fh or die;
         my $env = { PI_CONFIG => $cfgfile };
-        my $cmd = [ $httpd, '-W0', "--stdout=$out", "--stderr=$err" ];
-        $pid = spawn_listener($env, $cmd, [$sock]);
+        my $cmd = [ '-httpd', '-W0', "--stdout=$out", "--stderr=$err" ];
+        $td = start_script($cmd, $env, { 3 => $sock });
         $sock = undef;
 
         tiny_test($host, $port);