package zitcli
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
)
// ArtifactHead returns the FIRST n bytes of one stored artifact.
//
// ArtifactRaw exists for the case where a reader is being handed the
// whole file. This exists for the case where something only needs to
// know what the file IS — which magic number it starts with, whether it
// is text, what its first lines say — and paying a whole 32 MiB read
// through a subprocess for that answer is the difference between a page
// that renders and a page that does not.
//
// The child is cut off rather than drained: once n bytes are in hand
// the pipe is closed and the process killed, so a large blob costs the
// prefix and not the file. A blob shorter than n arrives whole and its
// own exit is waited for, which is what keeps a missing artifact
// distinguishable from a truncated one.
func (c *Client) ArtifactHead(ctx context.Context, hash string, n int) ([]byte, error) {
return c.headOutput(ctx, n, nil, "artifact", hash, "--raw", "-R", c.repo)
}
// ArtifactSize reports one artifact's byte count WITHOUT reading it.
// zit's summary names the size on its third line; a manifest's summary
// then runs to one line per file, so this stops at the line it came
// for the same way ArtifactHead stops at its bound.
func (c *Client) ArtifactSize(ctx context.Context, hash string) (int, error) {
// Enough for the hash, the type and the size. A summary whose size
// line is past this is a zit contract change, and the error below
// says so rather than answering zero.
const summaryPrefix = 4 << 10
// Stop at the line this came for rather than at the byte bound: the
// size is on the third line of every summary, and a file blob's
// summary is four lines that the bound alone would never cut short.
out, err := c.headOutput(ctx, summaryPrefix, sizeLineSeen,
"artifact", hash, "-R", c.repo)
if err != nil {
return 0, err
}
for _, line := range strings.Split(string(out), "\n") {
rest, ok := strings.CutPrefix(strings.TrimSpace(line), "size:")
if !ok {
continue
}
n, err := strconv.Atoi(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(rest), "bytes")))
if err != nil {
return 0, fmt.Errorf("zitcli: artifact %s: unreadable size line %q", hash, line)
}
return n, nil
}
return 0, fmt.Errorf("zitcli: artifact %s: summary carried no size line", hash)
}
// headOutput runs a zit command and keeps at most n bytes of what it
// writes, killing it once that much has arrived.
//
// The kill is conditional on the bound actually being reached. Killing
// unconditionally would make every exit status "signal: killed", and
// the exit status is how a missing artifact is told from an empty one.
func (c *Client) headOutput(ctx context.Context, n int, stop func([]byte) bool, args ...string) ([]byte, error) {
cmd, done := c.command(ctx, args...)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
// Killing the command kills the process we started — not anything
// IT started. A shell that has not exec'd its last command leaves a
// grandchild holding the write end of this pipe, and Wait then
// blocks on that pipe rather than on the process it reaped: cutting
// the child off at ten seconds and then waiting twenty more for its
// orphan, which is the whole hang this read's deadline was meant to
// end.
//
// WaitDelay bounds exactly that gap: the process is reaped, the
// pipes get a moment to close on their own, and then they are
// closed for them.
cmd.WaitDelay = waitDelay
pipe, err := cmd.StdoutPipe()
if err != nil {
return nil, c.storeFault(err, args[0]+" "+args[1], "")
}
if err := cmd.Start(); err != nil {
return nil, c.storeFault(err, args[0]+" "+args[1], stderr.String())
}
// A read with no deadline can be held for ever by a child that
// stops writing WITHOUT exiting — no bytes, no EOF, nothing to
// return. That is not hypothetical: it is why
// TestArtifactHeadStopsAtTheBound flaked, and the flake was
// answered twice by widening the test's own margin, which measures
// the symptom rather than bounding the wait.
//
// The deadline is per READ and refreshed by each one that lands
// (see readUntil), so it is a stall timeout and not a total one: a
// large artifact arriving steadily is never cut off, and a child
// that has gone quiet is.
buf, rerr := readUntil(pipe, n, stop)
// "Enough" is either the bound or the caller's own stopping point.
full := len(buf) >= n || (stop != nil && stop(buf))
stalled := errors.Is(rerr, errStalled)
if stalled {
// Not an error to report: the bytes that arrived are the
// answer, and whether they are enough is the caller's own
// question. It IS a reason to stop waiting on the child.
rerr = nil
}
if full || stalled {
pipe.Close()
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
}
werr := cmd.Wait()
if full {
// The child was killed on purpose, so its status says nothing.
// The bytes are what was asked for.
return buf, nil
}
if exitCode(werr) == 1 {
return nil, fmt.Errorf("zitcli: artifact %s: %w: %s",
args[1], ErrNoEntry, firstLine(stderr.String()))
}
if werr != nil {
return nil, c.storeFault(werr, args[0]+" "+args[1], stderr.String())
}
if rerr != nil {
return nil, c.storeFault(rerr, args[0]+" "+args[1], stderr.String())
}
return buf, nil
}
// readUntil reads at most n bytes, checking stop after each chunk so a
// caller that only wants one line does not wait for a child that has
// more to say.
func readUntil(r io.Reader, n int, stop func([]byte) bool) ([]byte, error) {
// A pipe from exec is an *os.File, which takes a deadline. Anything
// else (a test's bytes.Reader) reads without one, which is correct:
// it cannot block.
type deadliner interface{ SetReadDeadline(time.Time) error }
dl, canDeadline := r.(deadliner)
buf := make([]byte, 0, min(n, 32<<10))
chunk := make([]byte, 4<<10)
for len(buf) < n {
if stop != nil && stop(buf) {
return buf, nil
}
want := min(len(chunk), n-len(buf))
if canDeadline {
// Refreshed before every read, so the bound is on SILENCE
// rather than on the transfer: bytes arriving keep it open.
_ = dl.SetReadDeadline(time.Now().Add(readStall))
}
got, err := r.Read(chunk[:want])
buf = append(buf, chunk[:got]...)
if err != nil {
if err == io.EOF {
return buf, nil
}
// A stall is what this bound exists to end. Reported as
// its own sentinel rather than swallowed: whatever
// arrived is still the answer, but the CHILD has to be cut
// off too — otherwise the read gives up and cmd.Wait()
// waits for the same silence all over again, which is
// exactly the hang this was meant to end.
if errors.Is(err, os.ErrDeadlineExceeded) {
return buf, errStalled
}
return buf, err
}
}
return buf, nil
}
// waitDelay is how long Wait may spend on pipes a reaped child left
// open. Short: by the time it applies the process is already gone, and
// what is left is somebody else's file descriptor.
const waitDelay = 2 * time.Second
// errStalled says a read ended because the child went quiet, not
// because it finished. Internal: callers see the bytes that arrived.
var errStalled = errors.New("zitcli: child went quiet")
// readStall is how long a child may say nothing before the reader stops
// waiting. Generous: a store under load can take seconds to answer, and
// this is not a latency budget — it is the difference between a slow
// answer and no answer at all.
const readStall = 10 * time.Second
// sizeLineSeen reports that a summary's size line has arrived whole —
// the line, plus the newline that proves it is not still being written.
func sizeLineSeen(buf []byte) bool {
i := bytes.Index(buf, []byte("size:"))
return i >= 0 && bytes.IndexByte(buf[i:], '\n') >= 0
}