Skip to content

Instantly share code, notes, and snippets.

@webstrand
Created September 13, 2020 23:02
Show Gist options
  • Select an option

  • Save webstrand/6f84d457aa2a1e0c5fbb618da84de59e to your computer and use it in GitHub Desktop.

Select an option

Save webstrand/6f84d457aa2a1e0c5fbb618da84de59e to your computer and use it in GitHub Desktop.
Mostly complete script that runs and reloads an interactive command whenever files change.
#!/usr/bin/env perl
use utf8;
use warnings;
use v5.30;
use experimental qw(signatures);
use FindBin;
use Linux::Inotify2;
use Time::HiRes qw(ualarm);
unless(@ARGV) {
say STDERR <<EOF;
usage: $0 swipl -qs 01-list-last.pro
Runs the requested command interactively, interrupting and restarting it
whenever a file in this script's directory is changed. The command may exit
at any time and will not be restarted until a file change is detected.
Ctrl-C (SIGINT) is intentionally ignored, use Ctrl-\ (SIGQUIT) to kill any
running command and exit.
EOF
exit(1);
}
# ======================================================================
$SIG{INT} = 'IGNORE';
$SIG{QUIT} = sub { say "QUIT"; exit };
my $inotify = Linux::Inotify2->new();
$inotify->watch($FindBin::Bin, IN_CLOSE_WRITE);
my $child = InteractiveChildProc->new;
for(;;) {
print "\n===============================\n";
unless($child->respawn(@ARGV)) {
say STDERR "$ARGV[0]: $!";
exit 1;
}
wait_for_changes();
# Wait for the changes to settle, before continuing.
wait_for_no_changes(200);
}
# ======================================================================
sub wait_for_changes() {
{ redo unless $inotify->read() }
}
sub wait_for_no_changes($ms) {
local $SIG{ALRM} = sub {};
{ ualarm($ms * 1000); redo if $inotify->read() }
}
package InteractiveChildProc {
use warnings;
use v5.30;
use experimental qw(signatures);
use POSIX qw(:sys_wait_h _exit);
use IO::Pty;
use IO::Tty::Constant qw(TIOCGWINSZ TIOCSWINSZ);
use Syntax::Keyword::Try;
# private subs
sub close_all_fds;
sub watcher_proc;
use namespace::clean;
sub new($class) { bless do { \my $pid }, $class }
sub respawn($pid, $command, @args) {
$pid->abort;
$pid->spawn($command, @args);
}
sub spawn($pid, $command, @args) {
die "process $$pid already running" if defined $$pid;
# We need to catch and handle errors from exec in the child process. So
# we open a pipe and pass the writer end to the child process. If exec
# succeeds, the child will close the other end of the pipe. If it fails,
# the child will write the error into the pipe.
local $^F = 2;
pipe my $child_error, my $error or die "Unable to pipe: $!";
# The child_proc cleans up any unnecessary file descriptors and writes
# any exec errors to the $error pipe.
my sub child_proc() {
close_all_fds(\*STDIN, \*STDOUT, \*STDERR, $error);
no warnings qw(exec);
exec $command, @args;
print $error 0+$!;
$error->flush;
_exit 1;
}
# We spawn the watcher_proc, which will spawn the child process, to
# copy STDIN into the child and reap the child when it exits. The
# watcher_proc will not exit
unless($$pid = fork) {
try {
watcher_proc(\&child_proc);
}
catch {
warn $@;
}
finally {
warn "control-flow escaped watcher_proc in watcher process";
_exit 127;
}
}
else {
close $error;
$! = <$child_error>;
return not $!;
}
}
sub abort($pid) {
if(defined $$pid) {
# The watcher process only responds to
kill 'ABRT', $$pid;
scalar waitpid($$pid, 0);
$$pid = undef;
}
}
sub DESTROY($pid) {
$pid->abort;
}
sub watcher_proc($child_proc) {
# We need to be able to close the writer end of the child_proc's STDIN,
# to interrupt any blocking read and signal termination. So we create a
# new PTY to attach to the child.
local $^F = 2;
my $pty = IO::Pty->new;
my $slave = $pty->slave;
# We fork, replace the real STDIN with our fake one, and then spawn the
# requested child_proc.
my $child_pid;
unless($child_pid = fork) {
try {
close STDIN;
open STDIN, "<&", $slave or die "Can't dup slave pty: $!";
$pty->close;
$child_proc->();
}
catch {
warn $@;
}
finally {
warn "control-flow escaped child_proc in child process";
_exit 127;
}
}
# Can't close $slave, IO::Pty doesn't like that and throws an error
# when we later try to $pty->close().
close_all_fds(\*STDIN,\*STDOUT,\*STDERR,$pty,$slave);
$slave->close;
# TTYs generate a whole host of different signals. We only want the
# watcher_proc to exit when the main process signals it to exit.
$SIG{$_} = 'IGNORE' foreach(keys %SIG);
$SIG{ABRT} = sub {
# If the child_proc has already exited, we can exit immediately.
_exit 0 unless(defined $child_pid);
# Processes stuck on blocking reads of STDIN may ignore signals
# until STDIN is closed.
$pty->close;
kill HUP => $child_pid;
# We allow the process a maximum of two seconds to exit. Sleep will
# be interrupted by SIGCHLD if the child_proc responds to the
# signal and exits.
sleep(2);
# Our SIGCHLD handler clears the $child_pid when it reaps
# the process.
if(defined $child_pid) {
say STDERR "Child process failed to exit cleanly.";
kill KILL => $child_pid;
}
_exit 0;
};
# We accept SIGCHLD to both interrupt the sleep() called by our SIGABRT
# handler, and to undef $child_pid when it exits.
$SIG{CHLD} = sub {
while((my $pid = waitpid(-1, WNOHANG)) > 0) {
undef $child_pid if $child_pid == $pid;
}
};
$SIG{WINCH} = sub {
ioctl(STDIN, TIOCGWINSZ, my $winsize);
ioctl($pty, TIOCSWINSZ, $winsize);
};
# Copy the STDIN shared with the main process into the child_proc.
while(sysread(STDIN, my $str, 256)) {
syswrite($pty, $str);
}
_exit 0;
}
sub close_all_fds (@protected) {
opendir(my $dir, "/proc/self/fd");
my %protected = map { ref $_ ? fileno($_) : $_ => undef } @protected;
$protected{fileno $dir} = undef;
foreach(grep /\d/, readdir $dir) {
next if exists $protected{$_};
POSIX::close($_);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment