DBIx-QuickORM

 view release on metacpan or  search on metacpan

worktrees/dbic-compat-native-features/t/fork_safety.t  view on Meta::CPAN

use Test2::V0 '!meta', '!pass';
use POSIX ();
use Atomic::Pipe;
use Cpanel::JSON::XS ();

# Exercises the forked statement handle's pipe protocol and process-ownership
# guard directly, without standing up a database. A real child process writes
# controlled envelope frames (or dies mid-stream) so we can assert how the
# parent handle distinguishes a clean end, a child-reported error, and a
# truncated stream, and that an inherited copy in another process is inert.

use DBIx::QuickORM::STH::Fork;

# Minimal stand-in for the owning connection. STH::Fork only calls clear_fork
# on us (via its clear()); record the calls so the ownership guard is testable.
package My::Mock::Connection {
    sub new { bless {cleared => []}, shift }
    sub clear_fork { push @{$_[0]->{cleared}} => $_[1]; return }
    sub cleared { $_[0]->{cleared} }
}

my $JSON = Cpanel::JSON::XS->new->utf8(1)->convert_blessed(1)->allow_nonref(1);

# Fork a child that writes the given pre-encoded frames and then behaves per
# %opts: 'terminate' (default) closes the pipe cleanly, 'truncate' exits
# without closing politely (here just _exit, leaving no terminal frame).
sub fork_handle {
    my %params = @_;
    my $frames = $params{frames} // [];

    my ($rh, $wh) = Atomic::Pipe->pair(compression => 'zstd');
    my $pid = fork // die "fork failed: $!";

    unless ($pid) {    # child
        undef $rh;
        $wh->write_message($JSON->encode($_)) for @$frames;
        undef $wh;
        POSIX::_exit(0);
    }

    undef $wh;
    my $con = My::Mock::Connection->new;
    my $sth = DBIx::QuickORM::STH::Fork->new(
        connection => $con,
        source     => 'fake-source',
        pid        => $pid,
        pipe       => $rh,
    );

    return ($sth, $con);
}

subtest clean_stream => sub {
    my ($sth) = fork_handle(frames => [
        {result => 1},
        {row => {id => 1, name => 'a'}},
        {row => {id => 2, name => 'b'}},
        {done => 1},
    ]);

    is($sth->result, 1, "result frame decoded");
    is($sth->next, {id => 1, name => 'a'}, "first row");
    is($sth->next, {id => 2, name => 'b'}, "second row");
    is($sth->next, undef, "done frame ends the stream cleanly");
    ok($sth->done, "handle is finalized after a clean end");
};

subtest child_error_frame => sub {
    my ($sth) = fork_handle(frames => [
        {result => 1},
        {row => {id => 1}},
        {error => 'boom in the child'},
    ]);

    is($sth->result, 1, "result frame decoded");
    is($sth->next, {id => 1}, "row before the error");

    my $err = dies { $sth->next };
    like($err, qr/Forked query failed in the child process: boom in the child/, "child error surfaces with its message");
    ok($sth->done, "handle is finalized after a child error");
};

subtest error_before_result => sub {
    my ($sth) = fork_handle(frames => [
        {error => 'execute failed'},
    ]);

    my $err = dies { $sth->result };
    like($err, qr/Forked query failed in the child process: execute failed/, "error frame in place of the result surfaces");
    ok($sth->done, "handle is finalized");
};

subtest truncated_stream => sub {
    # Child sends a result and one row, then exits without a terminal frame.
    my ($sth) = fork_handle(frames => [
        {result => 1},
        {row => {id => 1}},
    ]);

    is($sth->result, 1, "result frame decoded");
    is($sth->next, {id => 1}, "the row that did arrive");

    my $err = dies { $sth->next };
    like($err, qr/truncated|before the child signalled completion/i, "EOF with no terminal frame is reported as truncation");
    ok($sth->done, "handle is finalized after truncation");
};

subtest ownership_guard => sub {
    # A handle owned by a different process (as if inherited across a fork)
    # must not reap the child or release the owner's fork slot.
    my ($rh, $wh) = Atomic::Pipe->pair(compression => 'zstd');
    my $pid = fork // die "fork failed: $!";
    unless ($pid) { undef $rh; undef $wh; POSIX::_exit(0) }
    undef $wh;

    my $con = My::Mock::Connection->new;
    my $sth = DBIx::QuickORM::STH::Fork->new(
        connection => $con,
        source     => 'fake-source',
        pid        => $pid,
        pipe       => $rh,
        owner_pid  => $pid + 1,    # pretend we are not the owner
    );

    ok(!$sth->in_owner_process, "not in the owning process");
    $sth->set_done;
    ok($sth->done, "set_done marks the inherited copy spent");
    is($con->cleared, [], "the owner's fork slot was not released by the inherited copy");

    # The real owner still has to reap the child; do it so the test is tidy.
    waitpid($pid, 0);
};

done_testing;



( run in 0.545 second using v1.01-cache-2.11-cpan-600a1bdf6e4 )