Skip to content
quarry Artifact [0ea033201c]
Login

Artifact [0ea033201c]

Artifact 0ea033201ce19f7ae9e67845a2c0dee701ca41966819fc3c3ae53b9a41beb44e:


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
}