about summary refs log tree commit homepage
path: root/lib/PublicInbox/Daemon.pm
diff options
context:
space:
mode:
Diffstat (limited to 'lib/PublicInbox/Daemon.pm')
-rw-r--r--lib/PublicInbox/Daemon.pm622
1 files changed, 315 insertions, 307 deletions
diff --git a/lib/PublicInbox/Daemon.pm b/lib/PublicInbox/Daemon.pm
index d08ce0f9..ec76d6b8 100644
--- a/lib/PublicInbox/Daemon.pm
+++ b/lib/PublicInbox/Daemon.pm
@@ -5,93 +5,157 @@
 # and designed for handling thousands of untrusted clients over slow
 # and/or lossy connections.
 package PublicInbox::Daemon;
-use strict;
-use v5.10.1;
+use v5.12;
 use Getopt::Long qw(:config gnu_getopt no_ignore_case auto_abbrev);
 use IO::Handle; # ->autoflush
 use IO::Socket;
-use POSIX qw(WNOHANG :signal_h);
+use File::Spec;
+use POSIX qw(WNOHANG :signal_h F_SETFD);
 use Socket qw(IPPROTO_TCP SOL_SOCKET);
 STDOUT->autoflush(1);
 STDERR->autoflush(1);
-use PublicInbox::DS qw(now);
+use PublicInbox::DS qw(now awaitpid);
 use PublicInbox::Listener;
 use PublicInbox::EOFpipe;
-use PublicInbox::Sigfd;
 use PublicInbox::Git;
 use PublicInbox::GitAsyncCat;
 use PublicInbox::Eml;
+use PublicInbox::Config;
+use PublicInbox::OnDestroy;
 our $SO_ACCEPTFILTER = 0x1000;
 my @CMD;
 my ($set_user, $oldset);
 my (@cfg_listen, $stdout, $stderr, $group, $user, $pid_file, $daemonize);
-my $worker_processes = 1;
-my @listeners;
-my %pids;
-my %tls_opt; # scheme://sockname => args for IO::Socket::SSL->start_SSL
+my ($nworker, @listeners, %WORKERS, %logs);
+my %tls_opt; # scheme://sockname => args for IO::Socket::SSL::SSL_Context->new
 my $reexec_pid;
 my ($uid, $gid);
 my ($default_cert, $default_key);
-my %KNOWN_TLS = ( 443 => 'https', 563 => 'nntps', 993 => 'imaps' );
-my %KNOWN_STARTTLS = ( 119 => 'nntp', 143 => 'imap' );
-
-sub accept_tls_opt ($) {
-        my ($opt_str) = @_;
-        # opt_str: opt1=val1,opt2=val2 (opt may repeat for multi-value)
-        require PublicInbox::TLS;
+my %KNOWN_TLS = (443 => 'https', 563 => 'nntps', 993 => 'imaps', 995 =>'pop3s');
+my %KNOWN_STARTTLS = (110 => 'pop3', 119 => 'nntp', 143 => 'imap');
+my %SCHEME2PORT = map { $KNOWN_TLS{$_} => $_ + 0 } keys %KNOWN_TLS;
+for (keys %KNOWN_STARTTLS) { $SCHEME2PORT{$KNOWN_STARTTLS{$_}} = $_ + 0 }
+$SCHEME2PORT{http} = 80;
+
+our ($parent_pipe, %POST_ACCEPT, %XNETD);
+our %WORKER_SIG = (
+        INT => \&worker_quit,
+        QUIT => \&worker_quit,
+        TERM => \&worker_quit,
+        TTIN => 'IGNORE',
+        TTOU => 'IGNORE',
+        USR1 => \&reopen_logs,
+        USR2 => 'IGNORE',
+        WINCH => 'IGNORE',
+        CHLD => \&PublicInbox::DS::enqueue_reap,
+);
+
+sub listener_opt ($) {
+        my ($str) = @_; # opt1=val1,opt2=val2 (opt may repeat for multi-value)
         my $o = {};
         # allow ',' as delimiter since '&' is shell-unfriendly
-        foreach (split(/[,&]/, $opt_str)) {
+        for (split(/[,&]/, $str)) {
                 my ($k, $v) = split(/=/, $_, 2);
-                push @{$o->{$k} ||= []}, $v;
+                push @{$o->{$k}}, $v;
         }
 
         # key may be a part of cert.  At least
         # p5-io-socket-ssl/example/ssl_server.pl has this fallback:
-        $o->{cert} //= [ $default_cert ];
+        $o->{cert} //= [ $default_cert ] if defined($default_cert);
         $o->{key} //= defined($default_key) ? [ $default_key ] : $o->{cert};
-        my %ctx_opt = (SSL_server => 1);
+        $o;
+}
+
+sub check_absolute ($$) {
+        my ($var, $val) = @_;
+        die <<EOM if index($val // '/', '/') != 0;
+$var must be an absolute path when using --daemonize: $val
+EOM
+}
+
+sub accept_tls_opt ($) {
+        my ($opt) = @_;
+        my $o = ref($opt) eq 'HASH' ? $opt : listener_opt($opt);
+        return if !defined($o->{cert});
+        require PublicInbox::TLS;
+        my @ctx_opt;
         # parse out hostname:/path/to/ mappings:
-        foreach my $k (qw(cert key)) {
-                my $x = $ctx_opt{'SSL_'.$k.'_file'} = {};
+        for my $k (qw(cert key)) {
+                $o->{$k} // next;
+                push(@ctx_opt, "SSL_${k}_file", {});
                 foreach my $path (@{$o->{$k}}) {
                         my $host = '';
                         $path =~ s/\A([^:]+):// and $host = $1;
-                        $x->{$host} = $path;
+                        $ctx_opt[-1]->{$host} = $path;
+                        check_absolute($k, $path) if $daemonize;
                 }
         }
-        my $ctx = IO::Socket::SSL::SSL_Context->new(%ctx_opt) or
-                die 'SSL_Context->new: '.PublicInbox::TLS::err();
-
-        # save ~34K per idle connection (cf. SSL_CTX_set_mode(3ssl))
-        # RSS goes from 346MB to 171MB with 10K idle NNTPS clients on amd64
-        # cf. https://rt.cpan.org/Ticket/Display.html?id=129463
-        my $mode = eval { Net::SSLeay::MODE_RELEASE_BUFFERS() };
-        if ($mode && $ctx->{context}) {
-                eval { Net::SSLeay::CTX_set_mode($ctx->{context}, $mode) };
-                warn "W: $@ (setting SSL_MODE_RELEASE_BUFFERS)\n" if $@;
-        }
+        \@ctx_opt;
+}
 
-        { SSL_server => 1, SSL_startHandshake => 0, SSL_reuse_ctx => $ctx };
+sub do_chown ($) {
+        $uid // return;
+        my ($path) = @_;
+        chown($uid, $gid, $path) or warn "chown $path: $!\n";
+}
+
+sub open_log_path ($$) { # my ($fh, $path) = @_; # $_[0] is modified
+        open $_[0], '>>', $_[1] or die "open(>> $_[1]): $!";
+        $_[0]->autoflush(1);
+        do_chown($_[1]);
+        $_[0];
 }
 
-sub load_mod ($) {
-        my ($scheme) = @_;
-        my $modc = "PublicInbox::\U$1";
+sub load_mod ($;$$) {
+        my ($scheme, $opt, $addr) = @_;
+        my $modc = "PublicInbox::\U$scheme";
+        $modc =~ s/S\z//;
         my $mod = $modc.'D';
         eval "require $mod"; # IMAPD|HTTPD|NNTPD|POP3D
         die $@ if $@;
-        my %xn = map { $_ => $mod->can($_) } qw(refresh post_accept);
-        $xn{tlsd} = $mod->new if $mod->can('refresh_groups'); #!HTTPD
-        my $tlsd = $xn{tlsd};
-        $xn{refresh} //= sub { $tlsd->refresh_groups(@_) };
-        $xn{post_accept} //= sub { $modc->new($_[0], $tlsd) };
-        $xn{af_default} = 'httpready' if $modc eq 'PublicInbox::HTTP';
+        my %xn;
+        my $tlsd = $xn{tlsd} = $mod->new;
+        my %env = map {
+                substr($_, length('env.')) => $opt->{$_}->[-1];
+        } grep(/\Aenv\./, keys %$opt);
+        $xn{refresh} = sub {
+                my ($sig) = @_;
+                local @ENV{keys %env} = values %env;
+                $tlsd->refresh_groups($sig);
+        };
+        $xn{post_accept} = $tlsd->can('post_accept_cb') ?
+                        $tlsd->post_accept_cb : sub { $modc->new($_[0], $tlsd) };
+        my @paths = qw(out err);
+        if ($modc eq 'PublicInbox::HTTP') {
+                @paths = qw(err);
+                $xn{af_default} = 'httpready';
+                if (my $p = $opt->{psgi}) {
+                        die "multiple psgi= options specified\n" if @$p > 1;
+                        check_absolute('psgi=', $p->[0]) if $daemonize;
+                        $tlsd->{psgi} = $p->[0];
+                        warn "# $scheme://$addr psgi=$p->[0]\n";
+                }
+        }
+        for my $f (@paths) {
+                my $p = $opt->{$f} or next;
+                die "multiple $f= options specified\n" if @$p > 1;
+                check_absolute("$f=", $p->[0]) if $daemonize;
+                $p = File::Spec->canonpath($p->[0]);
+                $tlsd->{$f} = $logs{$p} //= open_log_path(my $fh, $p);
+                warn "# $scheme://$addr $f=$p\n";
+        }
+        # for per-listener $SIG{__WARN__}:
+        my $err = $tlsd->{err};
+        $tlsd->{warn_cb} = sub {
+                print $err @_ unless PublicInbox::Eml::warn_ignore(@_)
+        };
+        $opt->{'multi-accept'} and
+                $xn{'multi-accept'} = $opt->{'multi-accept'}->[-1];
         \%xn;
 }
 
-sub daemon_prepare ($$) {
-        my ($default_listen, $xnetd) = @_;
+sub daemon_prepare ($) {
+        my ($default_listen) = @_;
         my $listener_names = {}; # sockname => IO::Handle
         $oldset = PublicInbox::DS::block_signals();
         @CMD = ($0, @ARGV);
@@ -104,7 +168,7 @@ options:
 
   -l ADDRESS    address to listen on$dh
   --cert=FILE   default SSL/TLS certificate
-  --key=FILE    default SSL/TLS certificate
+  --key=FILE    default SSL/TLS certificate key
   -W WORKERS    number of worker processes to spawn (default: 1)
 
 See public-inbox-daemon(8) and $prog(1) man pages for more.
@@ -113,11 +177,12 @@ EOF
                 'l|listen=s' => \@cfg_listen,
                 '1|stdout=s' => \$stdout,
                 '2|stderr=s' => \$stderr,
-                'W|worker-processes=i' => \$worker_processes,
+                'W|worker-processes=i' => \$nworker,
                 'P|pid-file=s' => \$pid_file,
                 'u|user=s' => \$user,
                 'g|group=s' => \$group,
                 'D|daemonize' => \$daemonize,
+                'multi-accept=i' => \$PublicInbox::Listener::MULTI_ACCEPT,
                 'cert=s' => \$default_cert,
                 'key=s' => \$default_key,
                 'help|h' => \(my $show_help),
@@ -125,14 +190,12 @@ EOF
         GetOptions(%opt) or die $help;
         if ($show_help) { print $help; exit 0 };
 
+        $_ = File::Spec->canonpath($_ // next) for ($stdout, $stderr);
         if (defined $pid_file && $pid_file =~ /\.oldbin\z/) {
                 die "--pid-file cannot end with '.oldbin'\n";
         }
         @listeners = inherit($listener_names);
-
-        # allow socket-activation users to set certs once and not
-        # have to configure each socket:
-        my @inherited_names = keys(%$listener_names) if defined($default_cert);
+        my @inherited_names = keys(%$listener_names);
 
         # ignore daemonize when inheriting
         $daemonize = undef if scalar @listeners;
@@ -141,27 +204,36 @@ EOF
                 $default_listen // die "no listeners specified\n";
                 push @cfg_listen, $default_listen
         }
-
+        my ($default_scheme) = (($default_listen // '') =~ m!\A([^:]+)://!);
         foreach my $l (@cfg_listen) {
                 my $orig = $l;
-                my $scheme = '';
-                if ($l =~ s!\A([^:]+)://!!) {
-                        $scheme = $1;
-                } elsif ($l =~ /\A(?:\[[^\]]+\]|[^:]+):([0-9])+/) {
-                        my $s = $KNOWN_TLS{$1} // $KNOWN_STARTTLS{$1};
-                        $scheme = $s if defined $s;
+                my ($scheme, $port, $opt);
+                $l =~ s!\A([a-z0-9]+)://!! and $scheme = $1;
+                $scheme //= $default_scheme;
+                if ($l =~ /\A(?:\[[^\]]+\]|[^:]+):([0-9]+)/) {
+                        $port = $1 + 0;
+                        $scheme //= $KNOWN_TLS{$port} // $KNOWN_STARTTLS{$port};
                 }
+                $scheme // die "unable to determine URL scheme of $orig\n";
+                if (!defined($port) && index($l, '/') != 0) { # AF_UNIX socket
+                        $port = $SCHEME2PORT{$scheme} //
+                                die "no port in listen=$orig\n";
+                        $l =~ s!\A([^/]+)!$1:$port! or
+                                die "unable to add port=$port to $l\n";
+                }
+                $l =~ s!/\z!!; # chop one trailing slash
                 if ($l =~ s!/?\?(.+)\z!!) {
-                        $tls_opt{"$scheme://$l"} = accept_tls_opt($1);
+                        $opt = listener_opt($1);
+                        $tls_opt{"$scheme://$l"} = accept_tls_opt($opt);
                 } elsif (defined($default_cert)) {
                         $tls_opt{"$scheme://$l"} = accept_tls_opt('');
-                } elsif ($scheme =~ /\A(?:https|imaps|imaps)\z/) {
+                } elsif ($scheme =~ /\A(?:https|imaps|nntps|pop3s)\z/) {
                         die "$orig specified w/o cert=\n";
                 }
-                $scheme =~ /\A(http|imap|nntp|pop3)/ and
-                        $xnetd->{$l} = load_mod($1);
-
-                next if $listener_names->{$l}; # already inherited
+                if ($listener_names->{$l}) { # already inherited
+                        $XNETD{$l} = load_mod($scheme, $opt, $l);
+                        next;
+                }
                 my (%o, $sock_pkg);
                 if (index($l, '/') == 0) {
                         $sock_pkg = 'IO::Socket::UNIX';
@@ -188,38 +260,43 @@ EOF
                 }
                 $o{Listen} = 1024;
                 my $prev = umask 0000;
-                my $s = eval { $sock_pkg->new(%o) };
-                warn "error binding $l: $! ($@)\n" unless $s;
+                my $s = eval { $sock_pkg->new(%o) } or
+                        warn "error binding $l: $! ($@)\n";
                 umask $prev;
-                if ($s) {
-                        $listener_names->{sockname($s)} = $s;
-                        $s->blocking(0);
-                        push @listeners, $s;
-                }
+                $s // next;
+                $s->blocking(0);
+                my $sockname = sockname($s);
+                warn "# bound $scheme://$sockname\n";
+                $XNETD{$sockname} //= load_mod($scheme, $opt);
+                $listener_names->{$sockname} = $s;
+                push @listeners, $s;
         }
 
         # cert/key options in @cfg_listen takes precedence when inheriting,
         # but map well-known inherited ports if --listen isn't specified
-        # at all
-        for my $sockname (@inherited_names) {
-                $sockname =~ /:([0-9]+)\z/ or next;
-                if (my $scheme = $KNOWN_TLS{$1}) {
-                        $tls_opt{"$scheme://$sockname"} ||= accept_tls_opt('');
-                } elsif (($scheme = $KNOWN_STARTTLS{$1})) {
-                        next if $tls_opt{"$scheme://$sockname"};
-                        $tls_opt{''} ||= accept_tls_opt('');
+        # at all.  This allows socket-activation users to set certs once
+        # and not have to configure each socket:
+        if (defined $default_cert) {
+                my ($stls) = (($default_scheme // '') =~ /\A(pop3|nntp|imap)/);
+                for my $x (@inherited_names) {
+                        $x =~ /:([0-9]+)\z/ or next; # no TLS for AF_UNIX
+                        if (my $scheme = $KNOWN_TLS{$1}) {
+                                $XNETD{$x} //= load_mod($scheme);
+                                $tls_opt{"$scheme://$x"} ||= accept_tls_opt('');
+                        } elsif (($scheme = $KNOWN_STARTTLS{$1})) {
+                                $XNETD{$x} //= load_mod($scheme);
+                                $tls_opt{"$scheme://$x"} ||= accept_tls_opt('');
+                        } elsif (defined $stls) {
+                                $tls_opt{"$stls://$x"} ||= accept_tls_opt('');
+                        }
                 }
         }
-
-        die "No listeners bound\n" unless @listeners;
-}
-
-sub check_absolute ($$) {
-        my ($var, $val) = @_;
-        if (defined $val && index($val, '/') != 0) {
-                die
-"--$var must be an absolute path when using --daemonize: $val\n";
+        if (defined $default_scheme) {
+                for my $x (@inherited_names) {
+                        $XNETD{$x} //= load_mod($default_scheme);
+                }
         }
+        die "No listeners bound\n" unless @listeners;
 }
 
 sub daemonize () {
@@ -230,9 +307,11 @@ sub daemonize () {
                         next unless -e $arg;
                         $ARGV[$i] = Cwd::abs_path($arg);
                 }
-                check_absolute('stdout', $stdout);
-                check_absolute('stderr', $stderr);
-                check_absolute('pid-file', $pid_file);
+                check_absolute('--stdout', $stdout);
+                check_absolute('--stderr', $stderr);
+                check_absolute('--pid-file', $pid_file);
+                check_absolute('--cert', $default_cert);
+                check_absolute('--key', $default_key);
 
                 chdir '/' or die "chdir failed: $!";
         }
@@ -260,22 +339,38 @@ EOF
         };
 
         if ($daemonize) {
-                my $pid = fork // die "fork: $!";
+                my $pid = PublicInbox::OnDestroy::fork_tmp;
                 exit if $pid;
-
                 open(STDIN, '+<', '/dev/null') or
                                         die "redirect stdin failed: $!\n";
                 open STDOUT, '>&STDIN' or die "redirect stdout failed: $!\n";
                 open STDERR, '>&STDIN' or die "redirect stderr failed: $!\n";
                 POSIX::setsid();
-                $pid = fork // die "fork: $!";
+                $pid = PublicInbox::OnDestroy::fork_tmp;
                 exit if $pid;
         }
         return unless defined $pid_file;
 
         write_pid($pid_file);
-        # for ->DESTROY:
-        bless { pid => $$, pid_file => \$pid_file }, __PACKAGE__;
+        on_destroy \&unlink_pid_file_safe_ish, \$pid_file;
+}
+
+sub has_busy_clients { # post_loop_do CB
+        my ($state) = @_;
+        my $now = now();
+        my $n = PublicInbox::DS::close_non_busy();
+        if ($n) {
+                if ($state->{-w} < now()) {
+                        warn "$$ quitting, $n client(s) left\n";
+                        $state->{-w} = now() + 5;
+                }
+                unless (defined $state->{0}) {
+                        $state->{0} = (split(/\s+/, $0))[0];
+                        $state->{0} =~ s!\A.*?([^/]+)\z!$1!;
+                }
+                $0 = "$state->{0} quitting, $n client(s) left";
+        }
+        $n; # true: loop continues, false: loop breaks
 }
 
 sub worker_quit { # $_[0] = signal name or number (unused)
@@ -284,49 +379,15 @@ sub worker_quit { # $_[0] = signal name or number (unused)
 
         $_->close foreach @listeners; # call PublicInbox::DS::close
         @listeners = ();
-        my $proc_name;
-        my $warn = 0;
+
         # drop idle connections and try to quit gracefully
-        PublicInbox::DS->SetPostLoopCallback(sub {
-                my ($dmap, undef) = @_;
-                my $n = 0;
-                my $now = now();
-                for my $s (values %$dmap) {
-                        $s->can('busy') or next;
-                        if ($s->busy) {
-                                ++$n;
-                        } else { # close as much as possible, early as possible
-                                $s->close;
-                        }
-                }
-                if ($n) {
-                        if (($warn + 5) < now()) {
-                                warn "$$ quitting, $n client(s) left\n";
-                                $warn = now();
-                        }
-                        unless (defined $proc_name) {
-                                $proc_name = (split(/\s+/, $0))[0];
-                                $proc_name =~ s!\A.*?([^/]+)\z!$1!;
-                        }
-                        $0 = "$proc_name quitting, $n client(s) left";
-                }
-                $n; # true: loop continues, false: loop breaks
-        });
+        @PublicInbox::DS::post_loop_do = (\&has_busy_clients, { -w => 0 })
 }
 
 sub reopen_logs {
-        if ($stdout) {
-                open STDOUT, '>>', $stdout or
-                        warn "failed to redirect stdout to $stdout: $!\n";
-                STDOUT->autoflush(1);
-                do_chown($stdout);
-        }
-        if ($stderr) {
-                open STDERR, '>>', $stderr or
-                        warn "failed to redirect stderr to $stderr: $!\n";
-                STDERR->autoflush(1);
-                do_chown($stderr);
-        }
+        $logs{$stdout} //= \*STDOUT if defined $stdout;
+        $logs{$stderr} //= \*STDERR if defined $stderr;
+        while (my ($p, $fh) = each %logs) { open_log_path($fh, $p) }
 }
 
 sub sockname ($) {
@@ -390,11 +451,12 @@ sub inherit ($) {
                 if (my $k = sockname($s)) {
                         my $prev_was_blocking = $s->blocking(0);
                         warn <<"" if $prev_was_blocking;
-Inherited socket (fd=$fd) is blocking, making it non-blocking.
+Inherited socket ($k fd=$fd) is blocking, making it non-blocking.
 Set 'NonBlocking = true' in the systemd.service unit to avoid stalled
 processes when multiple service instances start.
 
                         $listener_names->{$k} = $s;
+                        warn "# inherited $k fd=$fd\n";
                         push @rv, $s;
                 } else {
                         warn "failed to inherit fd=$fd (LISTEN_FDS=$fds)";
@@ -413,177 +475,145 @@ sub upgrade { # $_[0] = signal name or number (unused)
                         warn "BUG: .oldbin suffix exists: $pid_file\n";
                         return;
                 }
-                unlink_pid_file_safe_ish($$, $pid_file);
+                unlink_pid_file_safe_ish(\$pid_file);
                 $pid_file .= '.oldbin';
                 write_pid($pid_file);
         }
-        my $pid = fork;
-        unless (defined $pid) {
-                warn "fork failed: $!\n";
-                return;
-        }
-        if ($pid == 0) {
-                use Fcntl qw(FD_CLOEXEC F_SETFD F_GETFD);
+        my $pid = eval { PublicInbox::OnDestroy::fork_tmp };
+        if (!defined($pid)) {
+                warn "fork failed: $! $@\n";
+        } elsif ($pid == 0) {
                 $ENV{LISTEN_FDS} = scalar @listeners;
                 $ENV{LISTEN_PID} = $$;
                 foreach my $s (@listeners) {
                         # @listeners are globs with workers, PI::L w/o workers
                         $s = $s->{sock} if ref($s) eq 'PublicInbox::Listener';
-
-                        my $fl = fcntl($s, F_GETFD, 0);
-                        fcntl($s, F_SETFD, $fl &= ~FD_CLOEXEC);
+                        fcntl($s, F_SETFD, 0) // die "F_SETFD: $!";
                 }
                 exec @CMD;
                 die "Failed to exec: $!\n";
+        } else {
+                awaitpid($pid, \&upgrade_aborted);
+                $reexec_pid = $pid;
         }
-        $reexec_pid = $pid;
 }
 
-sub kill_workers ($) {
-        my ($sig) = @_;
-        kill $sig, keys(%pids);
-}
+sub kill_workers ($) { kill $_[0], values(%WORKERS) }
 
-sub upgrade_aborted ($) {
-        my ($p) = @_;
-        warn "reexec PID($p) died with: $?\n";
+sub upgrade_aborted {
+        my ($pid) = @_;
+        warn "reexec PID($pid) died with: $?\n";
         $reexec_pid = undef;
         return unless $pid_file;
 
         my $file = $pid_file;
         $file =~ s/\.oldbin\z// or die "BUG: no '.oldbin' suffix in $file";
-        unlink_pid_file_safe_ish($$, $pid_file);
+        unlink_pid_file_safe_ish(\$pid_file);
         $pid_file = $file;
         eval { write_pid($pid_file) };
         warn $@, "\n" if $@;
 }
 
-sub reap_children { # $_[0] = 'CHLD' or POSIX::SIGCHLD()
-        while (1) {
-                my $p = waitpid(-1, WNOHANG) or return;
-                if (defined $reexec_pid && $p == $reexec_pid) {
-                        upgrade_aborted($p);
-                } elsif (defined(my $id = delete $pids{$p})) {
-                        warn "worker[$id] PID($p) died with: $?\n";
-                } elsif ($p > 0) {
-                        warn "unknown PID($p) reaped: $?\n";
-                } else {
-                        return;
-                }
-        }
-}
-
-sub unlink_pid_file_safe_ish ($$) {
-        my ($unlink_pid, $file) = @_;
-        return unless defined $unlink_pid && $unlink_pid == $$;
+sub unlink_pid_file_safe_ish ($) {
+        my ($fref) = @_;
 
-        open my $fh, '<', $file or return;
+        open my $fh, '<', $$fref or return;
         local $/ = "\n";
         defined(my $read_pid = <$fh>) or return;
         chomp $read_pid;
-        if ($read_pid == $unlink_pid) {
-                Net::Server::Daemonize::unlink_pid_file($file);
-        }
+        Net::Server::Daemonize::unlink_pid_file($$fref) if $read_pid == $$;
 }
 
 sub master_quit ($) {
         exit unless @listeners;
         @listeners = ();
-        kill_workers($_[0]);
+        exit unless kill_workers($_[0]);
+}
+
+sub reap_worker { # awaitpid CB
+        my ($pid, $nr) = @_;
+        warn "worker[$nr] died \$?=$?\n" if $?;
+        delete $WORKERS{$nr};
+        exit if !@listeners && !keys(%WORKERS);
+        PublicInbox::DS::requeue(\&start_workers);
+}
+
+sub start_worker ($) {
+        my ($nr) = @_;
+        return unless @listeners;
+        my $pid = PublicInbox::DS::fork_persist;
+        if ($pid == 0) {
+                undef %WORKERS;
+                local $PublicInbox::DS::Poller; # allow epoll/kqueue
+                $set_user->() if $set_user;
+                PublicInbox::EOFpipe->new($parent_pipe, \&worker_quit);
+                worker_loop();
+                exit 0;
+        } else {
+                $WORKERS{$nr} = $pid;
+                awaitpid($pid, \&reap_worker, $nr);
+        }
+}
+
+sub start_workers {
+        my @idx = grep { !defined($WORKERS{$_}) } (0..($nworker - 1)) or return;
+        eval { start_worker($_) for @idx };
+        warn "E: $@\n" if $@;
+}
+
+sub trim_workers {
+        my @nr = grep { $_ >= $nworker } keys %WORKERS;
+        kill('TERM', @WORKERS{@nr});
 }
 
 sub master_loop {
-        pipe(my ($p0, $p1)) or die "failed to create parent-pipe: $!";
-        my $set_workers = $worker_processes;
+        local $parent_pipe;
+        pipe($parent_pipe, my $p1) or die "failed to create parent-pipe: $!";
+        my $set_workers = $nworker; # for SIGWINCH
         reopen_logs();
-        my $ignore_winch;
-        my $sig = {
+        my $msig = {
                 USR1 => sub { reopen_logs(); kill_workers($_[0]); },
                 USR2 => \&upgrade,
                 QUIT => \&master_quit,
                 INT => \&master_quit,
                 TERM => \&master_quit,
                 WINCH => sub {
-                        return if $ignore_winch || !@listeners;
-                        if (-t STDIN || -t STDOUT || -t STDERR) {
-                                $ignore_winch = 1;
-                                warn <<EOF;
-ignoring SIGWINCH since we are not daemonized
-EOF
-                        } else {
-                                $worker_processes = 0;
-                        }
+                        $nworker = 0;
+                        trim_workers();
                 },
                 HUP => sub {
-                        return unless @listeners;
-                        $worker_processes = $set_workers;
+                        $nworker = $set_workers; # undo WINCH
                         kill_workers($_[0]);
+                        PublicInbox::DS::requeue(\&start_workers)
                 },
                 TTIN => sub {
-                        return unless @listeners;
-                        if ($set_workers > $worker_processes) {
-                                ++$worker_processes;
+                        if ($set_workers > $nworker) {
+                                ++$nworker;
                         } else {
-                                $worker_processes = ++$set_workers;
+                                $nworker = ++$set_workers;
                         }
+                        PublicInbox::DS::requeue(\&start_workers);
                 },
                 TTOU => sub {
-                        $worker_processes = --$set_workers if $set_workers > 0;
+                        return if $nworker <= 0;
+                        --$nworker;
+                        trim_workers();
                 },
-                CHLD => \&reap_children,
+                CHLD => \&PublicInbox::DS::enqueue_reap,
         };
-        my $sigfd = PublicInbox::Sigfd->new($sig);
-        local @SIG{keys %$sig} = values(%$sig) unless $sigfd;
-        PublicInbox::DS::sig_setmask($oldset) if !$sigfd;
-        while (1) { # main loop
-                my $n = scalar keys %pids;
-                unless (@listeners) {
-                        exit if $n == 0;
-                        $set_workers = $worker_processes = $n = 0;
-                }
-
-                if ($n > $worker_processes) {
-                        while (my ($k, $v) = each %pids) {
-                                kill('TERM', $k) if $v >= $worker_processes;
-                        }
-                        $n = $worker_processes;
-                }
-                my $want = $worker_processes - 1;
-                if ($n <= $want) {
-                        PublicInbox::DS::block_signals() if !$sigfd;
-                        for my $i ($n..$want) {
-                                my $seed = rand(0xffffffff);
-                                my $pid = fork;
-                                if (!defined $pid) {
-                                        warn "failed to fork worker[$i]: $!\n";
-                                } elsif ($pid == 0) {
-                                        srand($seed);
-                                        eval { Net::SSLeay::randomize() };
-                                        $set_user->() if $set_user;
-                                        return $p0; # run normal work code
-                                } else {
-                                        warn "PID=$pid is worker[$i]\n";
-                                        $pids{$pid} = $i;
-                                }
-                        }
-                        PublicInbox::DS::sig_setmask($oldset) if !$sigfd;
-                }
-
-                if ($sigfd) { # Linux and IO::KQueue users:
-                        $sigfd->wait_once;
-                } else { # wake up every second
-                        sleep(1);
-                }
-        }
+        $msig->{WINCH} = sub {
+                warn "ignoring SIGWINCH since we are not daemonized\n";
+        } if -t STDIN || -t STDOUT || -t STDERR;
+        start_workers();
+        PublicInbox::DS::event_loop($msig, $oldset);
         exit # never gets here, just for documentation
 }
 
-sub tls_start_cb ($$) {
-        my ($opt, $orig_post_accept) = @_;
+sub tls_cb {
+        my ($post_accept, $tlsd) = @_;
         sub {
                 my ($io, $addr, $srv) = @_;
-                my $ssl = IO::Socket::SSL->start_SSL($io, %$opt);
-                $orig_post_accept->($ssl, $addr, $srv);
+                $post_accept->(PublicInbox::TLS::start($io, $tlsd), $addr, $srv)
         }
 }
 
@@ -597,100 +627,82 @@ sub defer_accept ($$) {
                 my $sec = unpack('i', $x);
                 return if $sec > 0; # systemd users may set a higher value
                 setsockopt($s, IPPROTO_TCP, $TCP_DEFER_ACCEPT, 1);
-        } elsif ($^O eq 'freebsd') {
+        } elsif ($^O =~ /\A(?:freebsd|netbsd|dragonfly)\z/) {
                 my $x = getsockopt($s, SOL_SOCKET, $SO_ACCEPTFILTER);
-                return if defined $x; # don't change if set
+                return if ($x // "\0") =~ /[^\0]/s; # don't change if set
                 my $accf_arg = pack('a16a240', $af_name, '');
                 setsockopt($s, SOL_SOCKET, $SO_ACCEPTFILTER, $accf_arg);
         }
 }
 
-sub daemon_loop ($) {
-        my ($xnetd) = @_;
-        my $refresh = sub {
+sub daemon_loop () {
+        local $PublicInbox::Config::DEDUPE = {}; # enable dedupe cache
+        my $refresh = $WORKER_SIG{HUP} = sub {
                 my ($sig) = @_;
-                for my $xn (values %$xnetd) {
+                %$PublicInbox::Config::DEDUPE = (); # clear cache
+                for my $xn (values %XNETD) {
+                        delete $xn->{tlsd}->{ssl_ctx}; # PublicInbox::TLS::start
                         eval { $xn->{refresh}->($sig) };
                         warn "refresh $@\n" if $@;
                 }
         };
-        my %post_accept;
-        while (my ($k, $v) = each %tls_opt) {
-                my $l = $k;
-                $l =~ s!\A([^:]+)://!!;
-                my $scheme = $1 // '';
-                my $xn = $xnetd->{$l} // $xnetd->{''};
-                if ($scheme =~ m!\A(?:https|imaps|nntps)!) {
-                        $post_accept{$l} = tls_start_cb($v, $xn->{post_accept});
-                } elsif ($xn->{tlsd}) { # STARTTLS, $k eq '' is OK
-                        $xn->{tlsd}->{accept_tls} = $v;
-                }
+        while (my ($k, $ctx_opt) = each %tls_opt) {
+                $ctx_opt // next;
+                my ($scheme, $l) = split(m!://!, $k, 2);
+                my $xn = $XNETD{$l} // die "BUG: no xnetd for $k";
+                $xn->{tlsd}->{ssl_ctx_opt} //= $ctx_opt;
+                $scheme =~ m!\A(?:https|imaps|nntps|pop3s)! and
+                        $POST_ACCEPT{$l} = tls_cb(@$xn{qw(post_accept tlsd)});
         }
-        my $sig = {
-                HUP => $refresh,
-                INT => \&worker_quit,
-                QUIT => \&worker_quit,
-                TERM => \&worker_quit,
-                TTIN => 'IGNORE',
-                TTOU => 'IGNORE',
-                USR1 => \&reopen_logs,
-                USR2 => 'IGNORE',
-                WINCH => 'IGNORE',
-                CHLD => \&PublicInbox::DS::enqueue_reap,
-        };
-        if ($worker_processes > 0) {
+        undef %tls_opt;
+        if ($nworker > 0) {
                 $refresh->(); # preload by default
-                my $fh = master_loop(); # returns if in child process
-                PublicInbox::EOFpipe->new($fh, \&worker_quit, undef);
+                return master_loop();
         } else {
                 reopen_logs();
                 $set_user->() if $set_user;
-                $sig->{USR2} = sub { worker_quit() if upgrade() };
+                $WORKER_SIG{USR2} = sub { worker_quit() if upgrade() };
                 $refresh->();
         }
+        local $PublicInbox::DS::Poller; # allow epoll/kqueue
+        worker_loop();
+}
+
+sub worker_loop {
         $uid = $gid = undef;
         reopen_logs();
         @listeners = map {;
                 my $l = sockname($_);
-                my $tls_cb = $post_accept{$l};
-                my $xn = $xnetd->{$l} // $xnetd->{''};
+                my $tls_cb = $POST_ACCEPT{$l};
+                my $xn = $XNETD{$l} // die "BUG: no xnetd for $l";
 
                 # NNTPS, HTTPS, HTTP, IMAPS and POP3S are client-first traffic
                 # IMAP, NNTP and POP3 are server-first
                 defer_accept($_, $tls_cb ? 'dataready' : $xn->{af_default});
 
                 # this calls epoll_create:
-                PublicInbox::Listener->new($_, $tls_cb || $xn->{post_accept})
+                PublicInbox::Listener->new($_, $tls_cb || $xn->{post_accept},
+                                                $xn->{'multi-accept'})
         } @listeners;
-        PublicInbox::DS::event_loop($sig, $oldset);
+        PublicInbox::DS::event_loop(\%WORKER_SIG, $oldset);
 }
 
 sub run {
         my ($default_listen) = @_;
-        my $xnetd = {};
-        if ($default_listen) {
-                $default_listen =~ /\A(http|imap|nntp|pop3)/ or
-                        die "BUG: $default_listen";
-                $xnetd->{''} = load_mod($1);
-        }
-        daemon_prepare($default_listen, $xnetd);
-        my $for_destroy = daemonize();
+        $nworker = 1;
+        local (%XNETD, %POST_ACCEPT);
+        daemon_prepare($default_listen);
+        my $unlink_on_leave = daemonize();
 
         # localize GCF2C for tests:
         local $PublicInbox::GitAsyncCat::GCF2C;
         local $PublicInbox::Git::async_warn = 1;
         local $SIG{__WARN__} = PublicInbox::Eml::warn_ignore_cb();
+        local %WORKER_SIG = %WORKER_SIG;
+        local %POST_ACCEPT;
 
-        daemon_loop($xnetd);
-        PublicInbox::DS->Reset;
-        # ->DESTROY runs when $for_destroy goes out-of-scope
-}
-
-sub do_chown ($) {
-        my ($path) = @_;
-        if (defined $uid and !chown($uid, $gid, $path)) {
-                warn "could not chown $path: $!\n";
-        }
+        daemon_loop();
+        # $unlink_on_leave runs
 }
 
 sub write_pid ($) {
@@ -699,8 +711,4 @@ sub write_pid ($) {
         do_chown($path);
 }
 
-sub DESTROY {
-        unlink_pid_file_safe_ish($_[0]->{pid}, ${$_[0]->{pid_file}});
-}
-
 1;