API-Docker

 view release on metacpan or  search on metacpan

t/read_timeout.t  view on Meta::CPAN

#   { line  => "half" }     part of one, with the rest still to come
#   { bytes => "abc" }      three bytes
#   { bytes => '' }         the clean end of the response
#   { }                     the same, which is what running off the end gives
#   { timeout => 1 }        the clock ran out: undef, with EAGAIN in errno
#   { eintr => 1 }          a signal interrupted it: undef, with EINTR
#   { fail => 1 }           undef with errno untouched, which is what a handle
#                           that reports nothing looks like
#
# `line` and `bytes` are the same delivery -- to sysread a line and a run of
# bytes are both just bytes -- and are kept apart only so a script reads as the
# wire it stands for.
#
# Running off the end of the script is the clean end, so a scenario only has to
# script as far as the site under test. Every one of these shapes was measured
# on a real socketpair with SO_RCVTIMEO set before being written down here,
# including the one that matters most: a clean end leaves errno untouched and
# returns 0, while an expiry sets EAGAIN and returns undef.
package Test::ReadTimeout::Handle;

sub TIEHANDLE {
  my ($class, $script) = @_;
  return bless { script => $script, i => 0 }, $class;
}

sub _next {
  my ($self) = @_;
  return $self->{script}[ $self->{i}++ ] || {};
}

# sysread reaches a tied handle through READ, so this is the whole interface
# the transport uses now -- READLINE is gone from here because nothing calls
# it any more: _read_line finds its terminator in the transport's own buffer.
sub READ {
  my $self = $_[0];
  my $act  = $self->_next;

  # sysread cannot deliver and expire in the same call, unlike the PerlIO
  # read() this harness was first written against. An entry that tries to be
  # both is a script that was not translated when karr k60 landed, and saying
  # so here is cheaper than the silent near-miss it would otherwise be.
  die "a scripted read cannot both deliver and expire\n"
    if ($act->{timeout} || $act->{eintr} || $act->{fail})
      && (defined $act->{line} || defined $act->{bytes});

  # Set last and read first: errno is only meaningful straight after the
  # operation that failed, which is exactly the discipline the code under
  # test has to keep. With keep_errno it is left exactly as the caller left
  # it, which is how a stale value gets in front of the check.
  unless ($act->{keep_errno}) {
    $! = $act->{timeout} ? Errno::EAGAIN()
      : $act->{eintr}    ? Errno::EINTR()
      : 0;
  }
  return undef if $act->{timeout} || $act->{eintr} || $act->{fail};

  my $data = defined $act->{line} ? $act->{line}
    : defined $act->{bytes} ? $act->{bytes} : '';
  $_[1] = $data;
  # 0 is the clean end of the response, and is the only thing that means it.
  return length $data;
}

sub CLOSE { 1 }

package main;

my $client = API::Docker->new(
  host        => 'unix:///nonexistent.sock',
  api_version => '1.41',
);

my $ENDPOINT = 'GET /v1.41/probe';

sub scripted {
  my (@script) = @_;
  no warnings "once";
  my $glob = \do { local *HANDLE };
  tie *$glob, 'Test::ReadTimeout::Handle', \@script;
  return $glob;
}

# A context with the clock running, and one without. The second is what every
# call made before this option existed passes, and it must leave every read
# site behaving exactly as it did.
sub ctx      { return { endpoint => $ENDPOINT, timeout => 2 } }
sub ctx_off  { return { endpoint => $ENDPOINT } }

# Run $code and report whether it raised a timeout, so the two meanings of one
# short read can be asserted side by side.
sub timed_out {
  my ($code) = @_;
  my @out = eval { $code->() };
  my $err = $@;
  return (undef, $err) if $err && ref $err
    && $err->isa('API::Docker::Error::Timeout');
  return (\@out, undef) unless $err;
  return (undef, undef, $err);
}

# Reading an attribute off whatever was raised, so a mutation that stops
# raising one fails the assertion it belongs to instead of dying and taking
# the rest of the file with it -- a red test has to stay readable.
sub attr {
  my ($err, $name) = @_;
  return ref $err && $err->can($name) ? $err->$name : undef;
}

# The pair of assertions every read site gets: with EAGAIN it croaks with a
# timeout, without it it does whatever a real end of the response means at
# that site. A test that only made the first half would pass just as well
# against a transport that croaked on every end of response.
#
# The second half is declared, not merely inspected. Each site says which of
# the two shapes it has -- `returns => sub {...}` for a site where the close
# really is the end of the response, `raises => sub {...}` for one where it is
# not -- and site_ok asserts that it is that shape and not the other one. The
# earlier version took a single check sub and ran it either way, so a site
# that started croaking where it used to return still passed as long as the
# check itself did not look at the return value. Four of the streaming sites
# below were in exactly that position when karr k64 landed: the check asserted

t/read_timeout.t  view on Meta::CPAN

    }
    else {
      ok !$eof_other, 'and raises nothing at all'
        or diag "raised: $eof_other";
      $eof{returns}->($eof_out) unless $eof_other;
    }

    # And with no timeout armed, the EAGAIN case is not a timeout either --
    # the option is what turns the check on, not the errno.
    my ($off_out, $off_err, $off_other) = timed_out(
      sub { $drive->($make_handle->(1), ctx_off()) });
    ok !$off_err, 'with no read_timeout set, EAGAIN is not consulted at all';
  };
}

# What a site that is cut short has to raise. The phase says which piece of
# the framing ended early, so a check that only asked for the class would pass
# against a transport that noticed the wrong one.
sub truncated_ok {
  my (%want) = @_;

  return sub {
    my ($err) = @_;
    isa_ok $err, 'API::Docker::Error::Truncated';
    is attr($err, 'phase'), $want{phase},
      'and says where the response was cut: ' . $want{phase};
    is attr($err, 'endpoint'), $ENDPOINT, 'and the request it belongs to';
    is attr($err, 'partial'), $want{partial},
      'carrying the bytes that did arrive'
      if exists $want{partial};
    is_deeply attr($err, 'summary'), $want{summary},
      'and the units the callback was handed'
      if exists $want{summary};
    # Where one phase covers two distinct ways of running out -- 'header-block'
    # is both "cut inside a field" and "the blank line never came" -- the phase
    # alone cannot fail when only one of the two checks is removed. The message
    # is what separates them, so a site that has two halves pins it.
    like "$err", $want{message}, 'and which of them ran out'
      if exists $want{message};
  };
}

my $HEAD_OK = { line => "HTTP/1.1 200 OK\r\n" };
my $BLANK   = { line => "\r\n" };

# ---------------------------------------------------------------------------
# _read_head -- the status line and the header block
# ---------------------------------------------------------------------------
site_ok '_read_head: the status line never arrives',
  sub { scripted({ timeout => $_[0] }) },
  sub { $client->_read_head($_[0], $_[1]) },
  raises => sub {
    like $_[0], qr/No response from Docker daemon/,
      'a daemon that closed without answering still says so, and says '
      . 'something else than a timeout';
  };

# The head, which karr k73 brought under the same check. These two sites used
# to be `returns` -- the first asserting that 'HTTP/1.1 20' came back as the
# status '20', the second that the headers arriving before the cut were kept
# -- on the reading that nothing in a head announces its own length, so there
# was no announcement to hold a short one against. What replaces that claim is
# not a softer version of it but its opposite: a head is framed by its
# terminators rather than by a length, so an end of stream where one belongs
# is decidable without anything to compare, exactly as it is for a chunk
# header one level down. Both are now `raises`.
#
# The claim these sites carry over is the one the file exists for, and it is
# untouched: the two meanings of the same empty read still come out
# differently, and only EAGAIN is a timeout. That distinction lives in _pull,
# below the new check, so nothing about it moved.
site_ok '_read_head: half a status line',
  sub { scripted({ line => 'HTTP/1.1 20' }, { timeout => $_[0] }) },
  sub { $client->_read_head($_[0], $_[1]) },
  raises => truncated_ok(phase => 'status-line', partial => '',
    message => qr/inside the status line, after 11 bytes of one/);

site_ok '_read_head: the header block stops halfway',
  sub {
    scripted($HEAD_OK, { line => "Content-Type: application/json\r\n" },
      { line => 'X-Half' }, { timeout => $_[0] });
  },
  sub { $client->_read_head($_[0], $_[1]) },
  raises => truncated_ok(phase => 'header-block', partial => '',
    message => qr/inside a header line, after 6 bytes of one/);

# The other half of the same phase, and the one the old header loop could not
# tell from a finished head at all: the block ends on a line boundary with the
# blank line never sent. `while (my $line = ...)` ended there silently, so a
# cut landing before Content-Length and Transfer-Encoding left neither, and
# _read_body then took the close-delimited branch where an EOF is the
# legitimate end.
site_ok '_read_head: the header block is never closed',
  sub {
    scripted($HEAD_OK, { line => "Content-Type: application/json\r\n" },
      { timeout => $_[0] });
  },
  sub { $client->_read_head($_[0], $_[1]) },
  raises => truncated_ok(phase => 'header-block', partial => '',
    message => qr/where a header line belongs, with no blank line/);

# ---------------------------------------------------------------------------
# _read_body -- the three shapes a buffered body comes in
# ---------------------------------------------------------------------------
# The four sites karr k64 changed. Each of them used to assert that the short
# read at a real end of response was RETURNED -- 'hello wor', 'hello' -- and
# named that as the silent loss the errno check could not prevent, the errno
# check being about a timeout and this being about a close. The claim that
# survives is the one those assertions were making about the timeout: the two
# meanings of an empty read still come out differently, and only one of them
# is a timeout. What is replaced is the other half of each pair, which is now
# an API::Docker::Error::Truncated instead of a value.
site_ok '_read_body: a content-length body stops short',
  sub {
    scripted({ bytes => 'hello ' }, { bytes => 'wor' },
      { timeout => $_[0] });
  },
  sub {
    $client->_read_body($_[0], { 'content-length' => 11 }, 'GET', $_[1]);
  },
  raises => truncated_ok(phase => 'content-length', partial => 'hello wor');

# The one body shape where an EOF is the end and must stay one: nothing was
# announced, so there is nothing to be short of.
site_ok '_read_body: a close-delimited body stops short',
  sub { scripted({ bytes => 'partial frames' }, { timeout => $_[0] }) },
  sub { $client->_read_body($_[0], {}, 'GET', $_[1]) },
  returns => sub {
    my ($out) = @_;
    is $out->[0], 'partial frames', 'the bytes are the body at a real close';
  };

site_ok '_read_chunked: the chunk header stops halfway',
  sub {
    scripted({ line => "5\r\n" }, { bytes => 'hello' }, { line => "\r\n" },
      { line => '1a' }, { timeout => $_[0] });
  },
  sub { $client->_read_chunked($_[0], $_[1]) },
  raises => truncated_ok(phase => 'chunk-header', partial => 'hello',
    message => qr/inside a chunk header, after 2 bytes of one/);

# The other half of the same phase (karr k77), the one _read_head's
# header-block already pins (karr k73): the stream ends where the next chunk
# header would start, with no terminating zero chunk ever sent. Undecidable
# from 'stops halfway' by phase alone -- both raise 'chunk-header' -- so only
# the message tells them apart, and only a script that never sends a byte of
# the next header exercises this half rather than the other one.
site_ok '_read_chunked: the chunk header never arrives',
  sub {
    scripted({ line => "5\r\n" }, { bytes => 'hello' }, { line => "\r\n" },
      { timeout => $_[0] });
  },
  sub { $client->_read_chunked($_[0], $_[1]) },
  raises => truncated_ok(phase => 'chunk-header', partial => 'hello',
    message => qr/where a chunk header belongs, with no terminating zero chunk/);

site_ok '_read_chunked: the chunk data stops short',
  sub {
    scripted({ line => "5\r\n" }, { bytes => 'hello' }, { line => "\r\n" },
      { line => "6\r\n" }, { bytes => ' wor' }, { timeout => $_[0] });
  },
  sub { $client->_read_chunked($_[0], $_[1]) },
  raises => truncated_ok(phase => 'chunk-data', partial => 'hello wor');

site_ok '_read_chunked: the CRLF after the chunk data never arrives',
  sub {
    scripted({ line => "5\r\n" }, { bytes => 'hello' },
      { timeout => $_[0] });
  },
  sub { $client->_read_chunked($_[0], $_[1]) },
  raises => truncated_ok(phase => 'chunk-terminator', partial => 'hello');

# ---------------------------------------------------------------------------
# _read_streaming_response -- the same three shapes, one callback at a time
# ---------------------------------------------------------------------------
sub chunk_handler {
  my ($got) = @_;
  return $client->_stream_handler($ENDPOINT, 'on_chunk',
    sub { push @$got, $_[0] }, 1);
}

t/read_timeout.t  view on Meta::CPAN

# The third site sharing this phase (karr k77), and the other half of it: the
# stream ends where the next chunk header would start rather than inside one.
# A third site is what made it worth re-checking the whole file for the same
# gap header-block had already closed at three sites of its own (karr k73) --
# this is the second phase found with two ways to run out and only one
# pinned, and it turned up twice over (buffered and streamed), not once.
{
  my @got;
  site_ok 'streaming, chunked: the chunk header never arrives',
    sub {
      scripted($HEAD_OK, { line => "Transfer-Encoding: chunked\r\n" }, $BLANK,
        { line => "5\r\n" }, { bytes => 'hello' }, { line => "\r\n" },
        { timeout => $_[0] });
    },
    drive_stream(\@got),
    raises => sub {
      truncated_ok(phase => 'chunk-header', partial => '',
        summary => { delivered => 1, stopped => 0 },
        message => qr/where a chunk header belongs, with no terminating zero chunk/)
        ->(@_);
      is_deeply \@got, ['hello', 'hello'],
        'both runs delivered the completed chunk';
    };
}

{
  my @got;
  site_ok 'streaming, chunked: the chunk data stops short',
    sub {
      scripted($HEAD_OK, { line => "Transfer-Encoding: chunked\r\n" }, $BLANK,
        { line => "6\r\n" }, { bytes => ' wor' }, { timeout => $_[0] });
    },
    drive_stream(\@got),
    raises => sub {
      truncated_ok(phase => 'chunk-data', partial => '',
        summary => { delivered => 1, stopped => 0 })->(@_);
      is_deeply \@got, [' wor', ' wor'],
        'every byte that arrived reached the callback before the exception '
        . 'was raised, so both runs deliver the same units and only the '
        . 'reason for stopping differs';
    };
}

{
  my @got;
  site_ok 'streaming, chunked: the CRLF after the chunk data never arrives',
    sub {
      scripted($HEAD_OK, { line => "Transfer-Encoding: chunked\r\n" }, $BLANK,
        { line => "5\r\n" }, { bytes => 'hello' }, { timeout => $_[0] });
    },
    drive_stream(\@got),
    raises => sub {
      truncated_ok(phase => 'chunk-terminator', partial => '',
        summary => { delivered => 1, stopped => 0 })->(@_);
      is_deeply \@got, ['hello', 'hello'], 'the chunk was delivered';
    };
}

{
  my @got;
  site_ok 'streaming, content-length: the body stops short',
    sub {
      scripted($HEAD_OK, { line => "Content-Length: 11\r\n" }, $BLANK,
        { bytes => 'hello ' }, { bytes => 'wor' }, { timeout => $_[0] });
    },
    drive_stream(\@got),
    raises => sub {
      truncated_ok(phase => 'content-length', partial => '',
        summary => { delivered => 2, stopped => 0 })->(@_);
      is_deeply \@got, ['hello ', 'wor', 'hello ', 'wor'],
        'same on the content-length path: nothing that arrived is dropped '
        . 'because the rest of it did not';
    };
}

# And the streamed half of the one shape with no announcement to fall short
# of. This is the raw-stream path -- attach, logs(follow), exec/start -- where
# a close is how every one of them finishes.
{
  my @got;
  site_ok 'streaming, close-delimited: the body stops short',
    sub {
      scripted($HEAD_OK, $BLANK,
        { bytes => 'frame one' }, { bytes => 'fra' }, { timeout => $_[0] });
    },
    drive_stream(\@got),
    returns => sub {
      is_deeply \@got, ['frame one', 'fra', 'frame one', 'fra'],
        'and on the raw-stream path, which is the one karr k52 hangs on and '
        . 'where it matters most -- and where the two bursts are two calls '
        . 'rather than one 64K read, which is karr k60';
    };
}

# ---------------------------------------------------------------------------
# What the exception carries
# ---------------------------------------------------------------------------
subtest 'the bytes that did arrive come out with the exception' => sub {
  subtest 'a content-length body' => sub {
    my $fh = scripted({ bytes => 'hello ' }, { bytes => 'wor' },
      { timeout => 1 });
    eval { $client->_read_body($fh, { 'content-length' => 11 }, 'GET', ctx()) };
    my $err = $@;
    isa_ok $err, 'API::Docker::Error::Timeout';
    is attr($err, "partial"), 'hello wor',
      'everything read so far, the bytes of the read that expired included';
    is attr($err, "summary"), undef, 'no summary: nothing was streamed';
  };

  subtest 'a chunked body keeps the chunk it stalled inside' => sub {
    my $fh = scripted({ line => "5\r\n" }, { bytes => 'hello' },
      { line => "\r\n" }, { line => "6\r\n" }, { bytes => ' wor' },
      { timeout => 1 });
    eval { $client->_read_chunked($fh, ctx()) };
    my $err = $@;
    isa_ok $err, 'API::Docker::Error::Timeout';
    is attr($err, "partial"), 'hello wor',
      'the completed chunk and the part of the one still arriving';
  };

  subtest 'a close-delimited body' => sub {
    my $fh = scripted({ bytes => 'partial frames' }, { timeout => 1 });
    eval { $client->_read_body($fh, {}, 'GET', ctx()) };
    isa_ok $@, 'API::Docker::Error::Timeout';
    is attr($@, "partial"), 'partial frames', 'the bytes the slurp had collected';
  };

  subtest 'a streamed request carries the summary instead' => sub {
    my @got;
    my $fh = scripted($HEAD_OK, { line => "Transfer-Encoding: chunked\r\n" },
      $BLANK,
      { line => "5\r\n" }, { bytes => 'hello' }, { line => "\r\n" },
      { line => "5\r\n" }, { bytes => 'there' }, { line => "\r\n" },
      { timeout => 1 });
    eval {
      $client->_read_streaming_response($fh, 'GET', chunk_handler(\@got), ctx());
    };
    my $err = $@;
    isa_ok $err, 'API::Docker::Error::Timeout';
    is_deeply attr($err, "summary"), { delivered => 2, stopped => 0 },
      'the units the callback did get, counted the way a clean end counts them';
    is attr($err, "partial"), '',
      'and no body: a streamed request keeps none by design';
    like attr($err, "message"), qr/2 units/, 'the message says so too';
  };

  subtest 'everything that arrived is delivered before the expiry' => sub {
    # This is the shape karr k52 actually has: everything the daemon had to
    # say arrives, and the socket then stays open and silent. Measured against
    # Podman 5.8.4 on an attach to an exited container: two frames, 42 bytes,
    # delivered 0 before karr k59 and 2 after.
    #
    # Under read() those 42 bytes and the expiry were one call, and k59 had to
    # rescue them out of it. Under sysread they are two -- the delivery, then
    # the silence -- and the property holds without a rescue, which is why the
    # script below has two entries where it used to have one. What is asserted
    # is the property, not the mechanism: at the moment the exception is
    # raised, the caller is holding everything the daemon sent.
    my @got;
    my $fh = scripted($HEAD_OK, $BLANK,
      { bytes => 'frame one and two' }, { timeout => 1 });
    eval {



( run in 1.785 second using v1.01-cache-2.11-cpan-54e63673c56 )