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 )