Fugu

 view release on metacpan or  search on metacpan

t/fugu/process.t  view on Meta::CPAN

#!/usr/bin/env perl
# ex:ts=8 sw=4:
use v5.36;
use Test::More;
use FindBin qw($RealBin);
use lib "$RealBin/../../lib";

use Cwd        ();
use File::Temp qw(tempdir);
use Fugu::CLI  qw(EXIT_ERROR);

use_ok('Fugu::Process');

# Test 2: Basic spawn and terminate
{
	my $result = Fugu::Process->spawn_command(
		cmd => [ 'sleep', '300' ],
	);

	ok( $result->{success}, 'Spawned sleep process' );
	ok( defined $result->{pid}, 'Got PID' );
	my $pid = $result->{pid};

	ok( Fugu::Process->is_alive($pid), 'Process is alive' );

	my $killed = Fugu::Process->terminate( $pid, grace_period => 2 );
	ok( $killed, 'Terminated process' );

	ok( !Fugu::Process->is_alive($pid), 'Process is dead' );
}

# Test 3: a process that exits at once still spawned successfully.
# The exec resolved, so the spawn is a success; the caller that needs
# the outcome uses run.
{
	my $result = Fugu::Process->spawn_command(
		cmd => [ 'sh', '-c', 'exit 1' ],
	);

	ok( $result->{success}, 'A fast exit is still a successful spawn' );
	ok( defined $result->{pid}, 'and carries the PID' );
	Fugu::Process->wait_exit( $result->{pid}, 2 );
}

# Test 3c: An exec that fails reports its own reason at once, through
# the close-on-exec pipe and not through a wait-and-guess sleep.
{
	my $start  = time;
	my $result = Fugu::Process->spawn_command(
		cmd => ['/nonexistent/definitely-not-a-command'],
	);
	my $elapsed = time - $start;

	ok( !$result->{success}, 'An exec failure is a failure' );
	like(
		$result->{error},
		qr/Cannot exec .*definitely-not-a-command/,
		'the error names the command'
	);
	like( $result->{error}, qr/No such file|not found/i,
		'and carries the reason from the system' );
	ok( $elapsed <= 2, 'the report does not wait for a sleep' );
}

# Test 4: Invalid command
{
	my $result = Fugu::Process->spawn_command( cmd => [] );

	ok( !$result->{success}, 'Rejected empty command' );
	like( $result->{error}, qr/non-empty arrayref/, 'and says why' );

	my $scalar = Fugu::Process->spawn_command( cmd => 'sleep 1' );
	ok( !$scalar->{success}, 'Rejected a non-arrayref command' );
}

# Test 8: is_alive edge cases
{
	ok( !Fugu::Process->is_alive(undef),  'undef PID is not alive' );
	ok( !Fugu::Process->is_alive(''),     'Empty PID is not alive' );
	ok( !Fugu::Process->is_alive('abc'),  'Non-numeric PID is not alive' );
	ok( !Fugu::Process->is_alive(999999), 'Non-existent PID is not alive' );
	ok( Fugu::Process->is_alive($$),      'Own PID is alive' );
}

# Test 9: wait_exit
{
	my $result = Fugu::Process->spawn_command(
		cmd => [ 'sleep', '1' ],
	);

	my $exited = Fugu::Process->wait_exit( $result->{pid}, 5 );
	ok( $exited, 'Process exited within timeout' );
}

# Test 10: wait_exit timeout
{
	my $result = Fugu::Process->spawn_command(
		cmd => [ 'sleep', '10' ],
	);

	my $exited = Fugu::Process->wait_exit( $result->{pid}, 1 );
	ok( !$exited, 'Timeout waiting for exit' );

	Fugu::Process->terminate( $result->{pid} );
}

# Test 11: Graceful and forced termination
{
	# A process that ignores SIGTERM (sleep handles it)
	my $result = Fugu::Process->spawn_command(
		cmd => [ 'sleep', '300' ],
	);

	my $start  = time;
	my $killed = Fugu::Process->terminate( $result->{pid}, grace_period => 2 );
	my $elapsed = time - $start;

	ok( $killed, 'Process terminated' );
	ok( $elapsed < 5, 'Terminated quickly (graceful)' );
}

# Test 13: I/O redirection
{
	my $tmpdir  = tempdir( CLEANUP => 1 );
	my $outfile = "$tmpdir/fugu-process-test.txt";

	my $result = Fugu::Process->spawn_command(
		cmd    => [ 'echo', 'test output' ],
		stdout => $outfile,
	);

	sleep 1;
	Fugu::Process->wait_exit( $result->{pid}, 2 );

	ok( -f $outfile, 'Output file created' );
	if ( -f $outfile ) {
		open my $fh, '<', $outfile
		    or do { fail("Cannot read $outfile: $!"); };
		my $content = <$fh>;
		close $fh;
		like( $content, qr/test output/, 'Output redirected correctly' );
	}

t/fugu/process.t  view on Meta::CPAN


	# The default path of execvp(3) does not hold the temporary
	# directory, so the bare name fails without PATH.
	my $r = Fugu::Process->run(
		cmd => ['fugu-env-prog'],
		env => {},
	);
	ok( !$r->{success}, 'the bare name fails without PATH' );
	like( $r->{error}, qr/Cannot exec fugu-env-prog/,
		'and the error names the command' );

	$r = Fugu::Process->run(
		cmd => ['fugu-env-prog'],
		env => { PATH => $dir },
	);
	ok( $r->{success}, 'the same name succeeds with PATH in env' );
};

subtest 'a bad env returns an error and starts nothing' => sub {
	my @bad = (
		[ 'a value that is not a hashref' => 'not-a-hashref' ],
		[ 'an empty name'                 => { ''     => 'x' } ],
		[ 'an equals sign in a name'      => { 'A=B'  => 'x' } ],
		[ 'a NUL byte in a name'          => { "A\0B" => 'x' } ],
		[ 'an undefined value'            => { A      => undef } ],
		[ 'a reference value'             => { A      => [] } ],
		[ 'a NUL byte in a value'         => { A      => "x\0y" } ],
		[ 'a wide character in a name'    => { "\x{263a}" => 'x' } ],
		[ 'a wide character in a value'   => { A => "\x{263a}" } ],
	);

	for my $case (@bad) {
		my ( $name, $env ) = @$case;

		my $r = Fugu::Process->run(
			cmd => [ $^X, '-e', '1' ],
			env => $env,
		);
		ok( !$r->{success}, "run rejects $name" );
		ok( $r->{error},    'and says why' );

		my $s = Fugu::Process->spawn_command(
			cmd => [ $^X, '-e', '1' ],
			env => $env,
		);
		ok( !$s->{success},    "spawn_command rejects $name" );
		ok( !exists $s->{pid}, 'and starts nothing' );
	}

	my $r = Fugu::Process->run(
		cmd => [ $^X, '-e', '1' ],
		env => 'not-a-hashref',
	);
	is( $r->{exit_code}, EXIT_ERROR, 'the run shape: EXIT_ERROR' );
	is( $r->{stdout},    '',         'stdout is empty' );
	is( $r->{stderr},    '',         'stderr is empty' );
};

# _gone_soon($pid):
#	Poll until the process is dead, for up to five seconds. The
#	is_alive call reaps a zombie child of the test. A group member
#	that is not a child of the test waits for init to reap it, so
#	it can answer for a moment.
sub _gone_soon ($pid)
{
	for ( 1 .. 100 ) {
		return 1 unless Fugu::Process->is_alive($pid);
		select undef, undef, undef, 0.05;
	}

	return 0;
}

# _read_pids($file):
#	Poll until the file holds two pids, then return them. The
#	child writes the file directly after its fork, so the wait is
#	short.
sub _read_pids ($file)
{
	for ( 1 .. 100 ) {
		if ( open my $fh, '<', $file ) {
			my $content = do { local $/; <$fh> };
			close $fh;

			# The newline proves that the write is complete,
			# so a partial pid can never match.
			my @pids = $content =~ /^(\d+) (\d+)\n\z/;
			return @pids if @pids == 2;
		}
		select undef, undef, undef, 0.05;
	}

	return;
}

# The leader-and-grandchild program. It forks a grandchild that
# sleeps, writes both pids to the named file, and sleeps itself.
my $LEADER_CODE =
      'my $pid = fork; die "fork: $!" unless defined $pid; '
    . 'if ($pid == 0) { sleep 60; exit 0 } '
    . 'open my $fh, ">", $ARGV[0] or die "open: $!"; '
    . 'print {$fh} "$$ $pid\n"; close $fh; '
    . 'sleep 60';

subtest 'run with new_session makes the child a group leader' => sub {
	my $r = Fugu::Process->run(
		cmd         => [ $^X, '-e', 'print "$$ ", getpgrp(0)' ],
		new_session => 1,
	);
	ok( $r->{success}, 'the child runs' );
	my ( $pid, $pgid ) = split / /, $r->{stdout};
	is( $pgid, $pid, 'the group id of the child equals its pid' );
};

subtest 'run without new_session keeps the child in the caller group' =>
    sub {
	my $r = Fugu::Process->run(
		cmd => [ $^X, '-e', 'print getpgrp(0)' ],
	);
	ok( $r->{success}, 'the child runs' );
	is( $r->{stdout}, getpgrp(0), 'the child stays in the group' );
    };

subtest 'run with new_session and a timeout stops the whole group' => sub {
	my $dir  = tempdir( CLEANUP => 1 );
	my $file = "$dir/pids.txt";



( run in 1.496 second using v1.01-cache-2.11-cpan-14f38c9f855 )