Daemonizer

Some time ago I found myself setting up yet another Sage server for the ISU Math Department. Each time I set one of these up, I try to do a better job than the time before, and I think I’m finally getting somewhere. However, I keep running into this problem: there’s no good way to run sage as a daemon.

The problem is that starting the sage notebook starts a shell script which starts a shell script which starts python which starts python and so on. This isn’t really that surprising for a service, but it ends up barfing all over the process table and doesn’t provide you with a nice way to clean up the mess. Of course when you run it in a shell and press control-C, everything cleans up fine but simply killing one of the processes won’t work.

So, how did I solve this? Well, like any good Unix admin, I wrote a short perl script. The basic idea is to emulate a shell. It forks, changes the process group id, and then executes so that everything that gets run from there on out is grouped together. It then leaves a helper process lying around (and will put its pid in a file) so that when you kill the helper, everything else dies as well.

The scrpt can be found here:

#! /usr/bin/perl
# Copyright (c) 2012 Faith Ekstrand and Iowa State University
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to permit
# persons to whom the Software is furnished to do so, subject to the
# following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
# NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
# USE OR OTHER DEALINGS IN THE SOFTWARE.

use strict;

my $daemon = 0;
my $help = 0;
my $pid_file = '';
my $command = '';
my $infile = '';
my $outfile = '';
my $errfile = '';
my $waittime = 5;

use POSIX;
use Getopt::Long;

my $good_opts = GetOptions ('help|h' => \$help, 'daemon|d!' => \$daemon,
        'pid-file|p=s' => \$pid_file, 'command|c=s' => \$command,
        'in-file=s' => \$infile, 'out-file=s' => \$outfile,
        'err-file=s' => \$errfile, 'wait-time|w=f' => \$waittime);

if ($help or not $good_opts) {
    print << "EOT";

Usage:
 daemonizer.pl [options] [-- command]

Options:
 -h, --help             print this help
 -d, --(no)daemon       run as a daemon (default: off)
 -c, --command=command  execute the given command
 -p, --pid-file=file    print the PID to the given file
 -w, --wait-time=time   specify how long (in minutes) we should wait to
            make sure that the child started correctly.
 --in-file=file         redirect standard input to a file
 --out-file=file        redirect standard output to a file
 --err-file=file        redirect standard error to a file (default: if
            standard output is redirected, redirect standard
            error to the same file)

EOT
    exit 0;
}

###
# Subroutines to handle daemonizing.
###

if (! $command) {
    $command = join(' ', @ARGV);
}

my $daemon_pgid;
sub daemon_signal_handler {
    my $signame = shift;

    print "Recieved SIG$signame, passing on to children...\n";

    if ($signame == 'INT') {
        kill -2, $daemon_pgid;
    } elsif ($signame == 'QUIT') {
        kill -3, $daemon_pgid;
    } elsif ($signame == 'TERM') {
        kill -15, $daemon_pgid;
    }
}

sub run_daemon {
    # Redirect standard input, output, and error as instructed (We want to
    # do this first so that even the daemonizer messages will get logged)
    if ($infile) {
        open STDIN, '<', $infile;
    }
    if ($outfile) {
        open STDOUT, '>', $outfile;
    }

    # If stderr is not specified, simply dump it to the same place as
    # stdout
    if ($errfile and $errfile != $outfile) {
        open STDERR, '>', $errfile;
    } elsif ($outfile) {
        open STDERR, '>&STDOUT';
    }

    # Now we can fork
    my $pid = fork;
    if (! $pid) {
        # Everything from here on out has this group id.
        setpgid(0, 0);
        exec $command;
    }

    # The child process will never get here
    $daemon_pgid = $pid;

    # We'll gracefully handle these two
    $SIG{'INT'} = \&daemon_signal_handler;
    $SIG{'QUIT'} = \&daemon_signal_handler;
    $SIG{'TERM'} = \&daemon_signal_handler;

    # We'll make the rash assumption that what we call won't exit until
    # everything else has
    waitpid $pid, 0;
    exit $?;
}

###
# Actually executing the process
###
if ($daemon) {
    my $pid = fork();
    if ($pid) {
        # Wait 5 seconds to make sure the server has properly started
        sleep $waittime;

        if (waitpid $pid, WNOHANG > 0) {
            exit $?;
        }

        if ($pid_file) {
            # If we have a pid file specified, record the pid of the daemon
            open PIDFILE, '>', $pid_file;
            print PIDFILE "$pid";
            close PIDFILE;
        }

        exit 0;
    } else {
        # Run the daemon
        run_daemon();
    }
} else {
    run_daemon();
}