package zitcli
import (
"bytes"
"context"
"errors"
"io"
"strconv"
"strings"
"testing"
"time"
)
// childLife is how long the fake child keeps writing after the prefix
// is already in hand; cutOffBy is the deadline a bounded read must beat.
//
// The margin was widened twice — 10s, then 25s — because this pair
// kept flaking, and both times the widening treated the symptom. The
// cause was in the code under test: killing the child did not cut off
// a GRANDCHILD holding the same pipe, so Wait blocked on the orphan
// for the rest of the child's lifetime and the read's own deadline
// bought nothing. headOutput sets WaitDelay now, so the wait is
// bounded rather than hoped for.
//
// Back to 10s with that fixed. The property is milliseconds against
// thirty seconds, so ten is still an enormous margin — and a margin
// that no longer has to cover somebody else's unbounded wait.
const (
childLife = 30 * time.Second
cutOffBy = 10 * time.Second
)
// childSeconds is childLife for a shell, which counts in whole seconds.
func childSeconds() string {
return strconv.Itoa(int(childLife / time.Second))
}
// The point of a bounded read is that it does NOT read the rest. A
// child that would go on writing for thirty seconds must be cut off
// once the prefix is in hand, not waited for — the old whole-blob read
// is exactly the cost this replaces.
func TestArtifactHeadStopsAtTheBound(t *testing.T) {
fakeZit(t, `printf '0123456789'; sleep `+childSleep())
c := &Client{repo: "tcl"}
done := make(chan struct{})
var got []byte
var err error
go func() {
got, err = c.ArtifactHead(context.Background(), "abcd", 4)
close(done)
}()
select {
case <-done:
case <-time.After(cutOffBy):
t.Fatal("FAIL: the bounded read waited for the child instead of cutting it off")
}
if err != nil {
t.Fatalf("FAIL: %v", err)
}
if string(got) != "0123" {
t.Fatalf("FAIL: read %q, want the first four bytes", got)
}
t.Log("ok: the head read takes its prefix and leaves the child behind")
}
// A blob shorter than the bound arrives whole, and the child's own exit
// is waited for rather than killed out from under it.
func TestArtifactHeadReturnsShortContentWhole(t *testing.T) {
fakeZit(t, `printf 'hi\n'`)
c := &Client{repo: "tcl"}
got, err := c.ArtifactHead(context.Background(), "abcd", 4096)
if err != nil {
t.Fatalf("FAIL: %v", err)
}
if string(got) != "hi\n" {
t.Fatalf("FAIL: read %q", got)
}
t.Log("ok: a blob smaller than the bound comes back entire")
}
// A hash the store does not hold is ErrNoEntry, the same answer the
// whole-blob read gives — the bound must not hide a real failure.
func TestArtifactHeadReportsAMissingArtifact(t *testing.T) {
fakeZit(t, `echo "no artifact matches" >&2; exit 1`)
c := &Client{repo: "tcl"}
if _, err := c.ArtifactHead(context.Background(), "abcd", 4096); !errors.Is(err, ErrNoEntry) {
t.Fatalf("FAIL: missing artifact answered %v, want ErrNoEntry", err)
}
t.Log("ok: a missing artifact is still ErrNoEntry under a bounded read")
}
// The size comes from the SUMMARY, which names it in one line, so
// learning how big a file is costs no bytes of the file.
func TestArtifactSizeReadsTheSummary(t *testing.T) {
fakeZit(t, `printf 'aaaa\ntype: file\nsize: 18349022 bytes\nbinary: yes\n'`)
c := &Client{repo: "tcl"}
n, err := c.ArtifactSize(context.Background(), "aaaa")
if err != nil {
t.Fatalf("FAIL: %v", err)
}
if n != 18349022 {
t.Fatalf("FAIL: size %d", n)
}
t.Log("ok: the size is parsed from the summary without reading the blob")
}
// A manifest's summary runs to thousands of F-card lines. The size is
// on the third; the reader must not sit through the rest.
//
// The fake child KEEPS WRITING rather than printing the prefix and
// sleeping, because a long summary is what this models and a pause is
// not. The difference is not cosmetic: readUntil consults its stop
// function on the bytes it already holds and then blocks on the next
// read, so a child that goes quiet mid-summary is waited for. With
// `printf` then `sleep`, whether that happened depended on whether the
// pipe delivered three lines in one chunk or two — usually one, so the
// test usually passed, and it failed under full-suite load looking
// exactly like a flake. Confirmed by feeding readUntil a reader that
// splits the prefix and then blocks: it waits for the whole child.
//
// Recorded rather than hardened: real zit writes a summary
// continuously, so the next chunk always arrives and the stop fires.
// A read deadline would close the latent case, and belongs with the
// context the caller passes rather than in a test fixture.
func TestArtifactSizeStopsAtTheSizeLine(t *testing.T) {
fakeZit(t, `printf 'aaaa\ntype: manifest\nsize: 50800 bytes\n'; `+
`end=$((SECONDS+`+childSeconds()+`)); while [ $SECONDS -lt $end ]; do `+
`printf 'F some/path/in/the/manifest %064d\n' $SECONDS; done`)
c := &Client{repo: "tcl"}
done := make(chan struct{})
var n int
var err error
go func() {
n, err = c.ArtifactSize(context.Background(), "aaaa")
close(done)
}()
select {
case <-done:
case <-time.After(cutOffBy):
t.Fatal("FAIL: the size read waited for the whole summary")
}
if err != nil || n != 50800 {
t.Fatalf("FAIL: size %d, err %v", n, err)
}
t.Log("ok: the size read stops at the line it came for")
}
func TestArtifactSizeReportsAMissingArtifact(t *testing.T) {
fakeZit(t, `echo "no artifact matches" >&2; exit 1`)
c := &Client{repo: "tcl"}
_, err := c.ArtifactSize(context.Background(), "abcd")
if !errors.Is(err, ErrNoEntry) {
t.Fatalf("FAIL: %v", err)
}
// A summary with no size line is a contract change, not a zero.
fakeZit(t, `printf 'aaaa\ntype: file\n'`)
if _, err := c.ArtifactSize(context.Background(), "aaaa"); err == nil ||
!strings.Contains(err.Error(), "size") {
t.Fatalf("FAIL: a summary with no size answered %v", err)
}
t.Log("ok: a missing artifact and a sizeless summary are both errors")
}
// childSleep renders childLife as the whole number of seconds `sleep`
// takes, so the shell script and the deadline cannot drift apart.
func childSleep() string {
return strconv.Itoa(int(childLife / time.Second))
}
// slowPipe hands out one chunk per call, so a reader's progress is
// countable: it models a child producing a long summary steadily.
type slowPipe struct {
chunks [][]byte
reads int
}
func (s *slowPipe) Read(p []byte) (int, error) {
if s.reads >= len(s.chunks) {
return 0, io.EOF
}
n := copy(p, s.chunks[s.reads])
s.reads++
return n, nil
}
// The property, asserted on BYTES rather than on a clock: a read that
// came for the size line stops at it and does not go on to the byte
// bound.
//
// The process-level test above can only observe how long the read took,
// and once its fake child writes continuously — which it must, or it
// blocks — a read to the 4KB bound finishes quickly too. So that test
// proves "did not wait for the whole child" and this one proves "did
// not read more than it needed". Both were wanted; only the first
// existed, and it was the one sensitive to load.
func TestReadUntilStopsAtTheLineNotTheBound(t *testing.T) {
const bound = 4 << 10
filler := bytes.Repeat([]byte("F some/path/in/the/manifest 0000\n"), 40)
p := &slowPipe{chunks: [][]byte{
[]byte("aaaa\ntype: manifest\n"), // no size line yet
[]byte("size: 50800 bytes\n"), // it arrives here
filler, filler, filler, filler, // and the summary goes on
}}
buf, err := readUntil(p, bound, sizeLineSeen)
if err != nil {
t.Fatalf("FAIL: %v", err)
}
if !sizeLineSeen(buf) {
t.Fatal("FAIL: the read returned without the line it came for")
}
// Two chunks: the prefix, then the one carrying the size line. A
// third means it kept reading after it had the answer.
if p.reads != 2 {
t.Errorf("FAIL: the read consumed %d chunks, want 2 — it went past the line it came for", p.reads)
}
if len(buf) >= bound {
t.Errorf("FAIL: the read ran to its %d-byte bound (%d bytes) instead of stopping at the line", bound, len(buf))
}
// And a SPLIT prefix must not defeat it: the line arriving in a
// later chunk than the first is the ordinary case, not an edge.
t.Logf("ok: stopped after %d bytes in %d chunks", len(buf), p.reads)
}
// A child that goes QUIET without exiting must not hold the reader.
// No bytes, no EOF, nothing to return — and before the read carried a
// deadline, nothing to stop waiting either. That is the real cause of
// this file's long-running flake, which was twice answered by widening
// the margin above rather than by bounding the wait.
func TestAQuietChildDoesNotHoldTheReader(t *testing.T) {
// Writes nothing at all, then sleeps far past any deadline here.
fakeZit(t, `sleep `+childSeconds())
c := &Client{repo: "tcl"}
done := make(chan struct{})
start := time.Now()
go func() {
_, _ = c.ArtifactHead(context.Background(), "abcd", 4)
close(done)
}()
select {
case <-done:
case <-time.After(readStall + 20*time.Second):
t.Fatal("FAIL: a silent child held the reader past its stall bound")
}
// And it waited for the stall rather than returning at once: the
// bound is on SILENCE, so a child that has said nothing YET is
// given its chance.
if waited := time.Since(start); waited < readStall {
t.Errorf("FAIL: gave up after %v, before the stall bound of %v", waited, readStall)
}
}