#! /usr/bin/perl # Jack Applin use strict; use warnings; use IO::Pty; use Getopt::Long qw(:config bundling require_order); use File::Temp 'tempdir'; use Cwd 'abs_path'; use Config; use POSIX 'sysconf'; # TODO: Separate stdout & stderr # TODO: Read a char if raw, a line if cooked # TODO: Kill program if some limit exceeded $0 =~ s!.*/!!; # Reduce program name to basename my $previous_io = "invalid value"; # Options: my $input_timeout = 0.01; # When reading from program, wait a centisecond my $crlf_translate = 1; # Translate CRLF => newline in program output my $debug = 0; # Produce debug output my $playpen = 0; # Don’t use a temporary playpen directory. my $max_cpu = 60; # Allow program 60 seconds of CPU time. my $max_real = 600; # Allow program 600 seconds of real time. my $max_eof = 20; # Allow program to read at EOF 20 times. my $max_lines = 10000; # Max lines the program may produce my $max_chars = 100000; # Max chars the program may produce # Program information my $pid; # Process ID of child program my $pty = new IO::Pty; my $exit_code; my $count_eof = 0; # How many times has the program read from EOF? my $count_lines = 0; # How many lines has the program emitted? my $count_chars = 0; # How many chars has the program emitted? # Emit this message to stderr, if debugging is enabled. sub bugout { warn "*** ", @_, "\n" if $debug; } sub sigchld { wait; $exit_code = $?; bugout "Caught SIGCHLD, exit code is $exit_code"; $SIG{CHLD} = \&sigchld; } $SIG{CHLD} = \&sigchld; sub spawn { my (@argv) = @_; # A relative path won’t work if we chdir to a playpen directory: $argv[0] = abs_path($argv[0]) if $playpen; # Create playpen dir in parent so our exit will clean it up: my $playpen_dir = $playpen ? tempdir("runner-XXXXXX", TMPDIR => 1, CLEANUP => 1) : "."; $pid = fork; die "$0: fork failed: $!\n" unless defined($pid); if ($pid == 0) { # child process my $slave = $pty->slave; my $fileno = $slave->fileno; $pty->make_slave_controlling_terminal; close $pty; # Reopen the standard file descriptors in the child to use the pty: open STDIN, "<&$fileno" or die "$0: can't reopen STDIN: $!\n"; open STDOUT, ">&$fileno" or die "$0: can't reopen STDOUT: $!\n"; open STDERR, ">&$fileno" or die "$0: can't reopen STDERR: $!\n"; close $slave; chdir $playpen_dir or die "$0: can't chdir $playpen_dir: $!\n"; exec @argv or die "$0: can't exec @argv: $!\n"; } bugout "Spawned @argv as pid $pid on ", $pty->ttyname, " in $playpen_dir"; $pty->close_slave; } sub show_exit_code() { bugout "exit code is ", $exit_code // "undefined"; return unless defined($exit_code); if ($exit_code & 127) { my $sig = (split / /,$Config{sig_name})[$exit_code & 127] || "UNKNOWN"; print "$ARGV[0] terminated with signal SIG$sig\n"; } } sub slurp($) { my ($filename) = @_; open my $fh, "<", $filename or return ""; local $/; # enable local slurp mode return <$fh> // ""; } # Given a process id, is that proces blocked reading from a tty? # Further, is this a new read, and not the same read that we noted last time? # It may be that the process hasn’t had time to do the previous read, yet. # # Java uses a threaded interpreter (at least on my machine) # so extend this concept to any of the threads. sub wants_input() { for my $dir () { # Is this process waiting for a read from a tty, or stuck in poll? next unless slurp("$dir/wchan") =~ /^(n_tty_read|poll_schedule_timeout)$/; # Fine, it’s reading. Is this the same old read as last time? my $io = slurp("$dir/io"); my $success = $io ne $previous_io; $previous_io = $io; return 1 if $success; } return 0; # no luck, indicate failure } # Get this only once: my $clock_ticks = POSIX::sysconf(&POSIX::_SC_CLK_TCK); sub check_cpu_time() { open my $fh, "/proc/$pid/stat" or return 0; my $line = <$fh>; defined($line) or return 0; my @data = split(/ /, $line); @data >= 14 or return 0; # It’s the 14th field my $used = $data[13] / $clock_ticks; warn "$0: program used $used seconds of CPU, max is $max_cpu.\n" if $used > $max_cpu; } sub check_real_time() { my $used = time() - $^T; warn "$0: program took $used seconds real time, max is $max_real.\n" if $used > $max_real; } sub check_eof_reads() { warn "$0: program read at end of file $count_eof times, max is $max_eof.\n" if $count_eof > $max_eof; } sub check_lines() { warn "$0: program wrote $count_lines lines, max is $max_lines.\n" if $count_lines > $max_lines; } sub check_chars() { warn "$0: program wrote $count_chars chars, max is $max_chars.\n" if $count_chars > $max_chars; } sub usage() { die "usage: $0 [options] program [args] [ \$input_timeout, "maxeof=i" => \$max_eof, "maxcpu=i" => \$max_cpu, "maxreal=i" => \$max_real, "maxchars=i" => \$max_chars, "maxlines=i" => \$max_lines, "p|playpen!" => \$playpen, "crlf!" => \$crlf_translate, "d|debug!" => \$debug) or usage(); @ARGV or usage(); spawn(@ARGV); until (check_cpu_time() || check_real_time() || check_eof_reads() || check_lines() || check_chars()) { vec(my $rin = '', fileno($pty), 1) = 1; if (select($rin, undef, undef, $input_timeout) > 0) { my $count = $pty->sysread(my $output, 8192); bugout "sysread from program returned ", ($count // "undef"); last unless $count; # end on EOF or error $output =~ s/\r\n/\n/g if $crlf_translate; $count_lines += ($output =~ tr/\n/\n/); $count_chars += length($output); print $output; $|=1; } elsif (wants_input()) { my $line = ; bugout "read ", (defined($line) ? length($line) : "undef"), " stdin bytes"; unless (defined($line)) { ++$count_eof; $line = "\cD"; # control-D means EOF for a tty/pty } $line =~ tr/\n/\r/; # line from file has \n, pty wants \r $pty->syswrite($line); # Send it to program } } show_exit_code(); $exit_code ||= 0; # In case it’s undefined exit $exit_code;