2026-10-25 20:29:03 +01:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
2026-10-26 16:44:11 +01:00
|
|
|
"fmt"
|
2026-10-25 20:29:03 +01:00
|
|
|
"io"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
|
|
|
|
|
"github.com/git-pkgs/artifacts"
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/metrics"
|
|
|
|
|
"github.com/git-pkgs/registries/fetch"
|
|
|
|
|
"github.com/opencontainers/go-digest"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-26 16:44:11 +01:00
|
|
|
// On a cache miss a marked request is sent the bytes as they arrive from
|
|
|
|
|
// upstream while they are stored, rather than after the whole artifact is
|
|
|
|
|
// stored. Clients with idle timeouts need this for large artifacts: git-lfs
|
|
|
|
|
// gives up after 30 seconds without data, pacman after 10. The caller that
|
|
|
|
|
// starts the shared fetch is handed the stream; callers joining it still wait
|
|
|
|
|
// for the stored artifact.
|
2026-10-25 20:29:03 +01:00
|
|
|
|
|
|
|
|
type streamCacheMissKey struct{}
|
|
|
|
|
|
|
|
|
|
// StreamCacheMiss marks ctx so that on an artifact cache miss the bytes are
|
|
|
|
|
// sent to the client while they are being stored. The server sets it on GET
|
|
|
|
|
// requests without a Range header; a range of a stream that is still
|
|
|
|
|
// arriving can't be served.
|
|
|
|
|
func StreamCacheMiss(ctx context.Context) context.Context {
|
|
|
|
|
return context.WithValue(ctx, streamCacheMissKey{}, true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func streamsCacheMiss(ctx context.Context) bool {
|
|
|
|
|
stream, _ := ctx.Value(streamCacheMissKey{}).(bool)
|
|
|
|
|
return stream && !servingHead(ctx)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// missHandoff passes the client's stream from the shared fetch, which runs on
|
|
|
|
|
// its own goroutine, to the caller that started it. The caller can leave
|
|
|
|
|
// before a stream is offered, and the fetch must then not write to a stream
|
|
|
|
|
// nobody reads.
|
|
|
|
|
type missHandoff struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
ready chan struct{}
|
|
|
|
|
stream *CacheResult
|
|
|
|
|
taken bool
|
|
|
|
|
abandoned bool
|
2026-10-27 12:30:12 +01:00
|
|
|
// feed is the stream's source. Set and read only on the fetch's
|
|
|
|
|
// goroutine.
|
|
|
|
|
feed *missFeed
|
2026-10-25 20:29:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type missHandoffKey struct{}
|
|
|
|
|
|
|
|
|
|
func handoffFrom(ctx context.Context) *missHandoff {
|
|
|
|
|
h, _ := ctx.Value(missHandoffKey{}).(*missHandoff)
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// offer hands stream to the caller and reports whether it took it.
|
|
|
|
|
func (h *missHandoff) offer(stream *CacheResult) bool {
|
|
|
|
|
h.mu.Lock()
|
|
|
|
|
defer h.mu.Unlock()
|
|
|
|
|
if h.abandoned {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
h.stream, h.taken = stream, true
|
|
|
|
|
close(h.ready)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 12:30:12 +01:00
|
|
|
// holdUnrecorded gives a client that fell behind, and reads the stored copy
|
|
|
|
|
// of u, its own hold on u until it closes, so u isn't deleted under it.
|
|
|
|
|
func (h *missHandoff) holdUnrecorded(u *unrecordedObject) {
|
|
|
|
|
if h.feed != nil {
|
|
|
|
|
h.feed.holdUntilClose(u)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-25 20:29:03 +01:00
|
|
|
// handedOver reports whether the caller took a stream.
|
|
|
|
|
func (h *missHandoff) handedOver() bool {
|
|
|
|
|
h.mu.Lock()
|
|
|
|
|
defer h.mu.Unlock()
|
|
|
|
|
return h.taken
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// runSharedFetchStreaming runs the shared fetch on its own goroutine and
|
|
|
|
|
// returns as soon as it offers a stream, or with its result if it finishes
|
|
|
|
|
// without one: on a recheck hit, an artifact another instance stored, an
|
|
|
|
|
// error, or an artifact a block-mode scanner has to see first. Waiters and
|
|
|
|
|
// the fetch lease still see the fetch end only once the artifact is stored.
|
|
|
|
|
func (p *Proxy) runSharedFetchStreaming(ctx context.Context, key string, f *inflightFetch, recheck func() (artifacts.Artifact, string, bool), commit func(context.Context) (artifacts.Artifact, string, error)) (*CacheResult, error) {
|
|
|
|
|
h := &missHandoff{ready: make(chan struct{})}
|
|
|
|
|
type outcome struct {
|
|
|
|
|
res *CacheResult
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
done := make(chan outcome, 1)
|
|
|
|
|
go func() {
|
|
|
|
|
res, err := p.runSharedFetch(context.WithValue(ctx, missHandoffKey{}, h), key, f, recheck, commit)
|
|
|
|
|
h.mu.Lock()
|
|
|
|
|
defer h.mu.Unlock()
|
|
|
|
|
if h.taken || h.abandoned {
|
|
|
|
|
// The caller has the stream, or has gone; nobody reads this.
|
|
|
|
|
if res != nil && res.Reader != nil {
|
|
|
|
|
_ = res.Reader.Close()
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
done <- outcome{res, err}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-h.ready:
|
|
|
|
|
return h.stream, nil
|
|
|
|
|
case o := <-done:
|
|
|
|
|
return o.res, o.err
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
h.mu.Lock()
|
|
|
|
|
defer h.mu.Unlock()
|
|
|
|
|
if h.taken {
|
|
|
|
|
return h.stream, nil
|
|
|
|
|
}
|
|
|
|
|
h.abandoned = true
|
|
|
|
|
select {
|
|
|
|
|
case o := <-done:
|
|
|
|
|
if o.res != nil && o.res.Reader != nil {
|
|
|
|
|
_ = o.res.Reader.Close()
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
return nil, ctx.Err()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// storeFetched stores an artifact fetched from upstream. When the caller
|
|
|
|
|
// that started the fetch is waiting for a stream, it is sent the bytes as
|
|
|
|
|
// they are stored; see storeWhileServing.
|
|
|
|
|
func (p *Proxy) storeFetched(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared string, artifact *fetch.Artifact) (artifacts.Artifact, string, error) {
|
|
|
|
|
h := handoffFrom(ctx)
|
|
|
|
|
if h == nil || p.Scanners.Blocks(ecosystem) {
|
|
|
|
|
return p.storeArtifact(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared, artifact)
|
|
|
|
|
}
|
|
|
|
|
return p.storeWhileServing(ctx, h, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared, artifact)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// errNoReaderLeft stops reading upstream once neither storage nor the
|
|
|
|
|
// client takes the bytes any more.
|
|
|
|
|
var errNoReaderLeft = errors.New("neither storage nor the client is reading the artifact")
|
|
|
|
|
|
|
|
|
|
// storeWhileServing copies the upstream body to storage and to the client at
|
2026-10-26 16:44:11 +01:00
|
|
|
// once, at the upstream's speed; see missFeed for a client that can't keep up.
|
|
|
|
|
// The client's copy is checked before its last bytes, so a mismatch aborts
|
|
|
|
|
// the response. A storage failure leaves the client's copy running.
|
2026-10-25 20:29:03 +01:00
|
|
|
func (p *Proxy) storeWhileServing(ctx context.Context, h *missHandoff, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared string, artifact *fetch.Artifact) (artifacts.Artifact, string, error) {
|
|
|
|
|
hash := strings.ToLower(upstreamHash)
|
|
|
|
|
checks, err := newIntegrityChecks(hash, declared)
|
|
|
|
|
if err != nil {
|
|
|
|
|
_ = artifact.Body.Close()
|
|
|
|
|
return artifacts.Artifact{}, "", err
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 16:44:11 +01:00
|
|
|
feed := newMissFeed(clientFeedLimit)
|
|
|
|
|
var client io.ReadCloser = feed
|
2026-10-25 20:29:03 +01:00
|
|
|
if hash != "" || declared != "" {
|
2026-10-26 16:44:11 +01:00
|
|
|
if client, err = checks.wrapFailOnMismatch(feed, func(reason string) {
|
2026-10-25 20:29:03 +01:00
|
|
|
p.Logger.Warn("artifact sent on a cache miss failed its integrity check; aborting the response",
|
|
|
|
|
"purl", versionPURL, "filename", filename, "reason", reason)
|
|
|
|
|
}); err != nil {
|
|
|
|
|
_ = artifact.Body.Close()
|
|
|
|
|
return artifacts.Artifact{}, "", err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
stream := &CacheResult{
|
|
|
|
|
Reader: client,
|
|
|
|
|
Artifact: artifacts.Artifact{
|
|
|
|
|
PURL: versionPURL,
|
|
|
|
|
Filename: filename,
|
|
|
|
|
MediaType: artifact.ContentType,
|
|
|
|
|
// Unknown until stored, so the response goes out chunked and
|
|
|
|
|
// can still be aborted.
|
|
|
|
|
Size: -1,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if hash != "" {
|
|
|
|
|
stream.Artifact.Digest = digest.Digest("sha256:" + hash)
|
|
|
|
|
}
|
|
|
|
|
if !h.offer(stream) {
|
2026-10-26 16:44:11 +01:00
|
|
|
_ = feed.Close()
|
2026-10-25 20:29:03 +01:00
|
|
|
return p.storeArtifact(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared, artifact)
|
|
|
|
|
}
|
2026-10-27 12:30:12 +01:00
|
|
|
h.feed = feed
|
2026-10-25 20:29:03 +01:00
|
|
|
|
|
|
|
|
storeR, storeW := io.Pipe()
|
|
|
|
|
type stored struct {
|
|
|
|
|
artifact artifacts.Artifact
|
|
|
|
|
path string
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
storeDone := make(chan stored, 1)
|
|
|
|
|
go func() {
|
|
|
|
|
// Not cancelled with ctx: the client's request ends once it has the
|
|
|
|
|
// last byte, while storage may still be finishing the upload and the
|
|
|
|
|
// database write. Reading the upstream still stops with ctx, which
|
|
|
|
|
// ends the copy to storage too.
|
|
|
|
|
a, path, err := p.storeArtifact(context.WithoutCancel(ctx), ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared,
|
|
|
|
|
&fetch.Artifact{Body: storeR, Size: artifact.Size, ContentType: artifact.ContentType, ETag: artifact.ETag})
|
|
|
|
|
// storeArtifact closes storeR; this covers a return before it read
|
|
|
|
|
// anything, so the copy below never blocks on it.
|
|
|
|
|
_ = storeR.Close()
|
|
|
|
|
storeDone <- stored{a, path, err}
|
|
|
|
|
}()
|
|
|
|
|
|
2026-10-26 16:44:11 +01:00
|
|
|
readErr := copyToBoth(artifact.Body, storeW, feed)
|
2026-10-25 20:29:03 +01:00
|
|
|
_ = artifact.Body.Close()
|
|
|
|
|
_ = storeW.CloseWithError(readErr)
|
2026-10-26 16:44:11 +01:00
|
|
|
feed.end(readErr)
|
2026-10-25 20:29:03 +01:00
|
|
|
|
|
|
|
|
out := <-storeDone
|
2026-10-26 16:44:11 +01:00
|
|
|
if feed.lagging() {
|
2026-10-27 12:30:12 +01:00
|
|
|
// Opened before returning, so an unrecorded object is still there;
|
|
|
|
|
// runSharedFetch then gives the client a hold on it.
|
2026-10-26 16:44:11 +01:00
|
|
|
feed.resolve(p.openStoredForFeed(ctx, out.artifact, out.path, out.err))
|
|
|
|
|
}
|
|
|
|
|
if out.err != nil && readErr == nil && feed.fedFully() {
|
2026-10-25 20:29:03 +01:00
|
|
|
p.Logger.Error("artifact sent to the client but not cached",
|
|
|
|
|
"purl", versionPURL, "filename", filename, "url", upstreamURL, "error", out.err)
|
|
|
|
|
metrics.RecordArtifactServedUncached(ecosystem, metrics.FailureDetail{
|
|
|
|
|
PURL: versionPURL, Filename: filename, URL: upstreamURL, Err: out.err})
|
|
|
|
|
}
|
|
|
|
|
return out.artifact, out.path, out.err
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 16:44:11 +01:00
|
|
|
// openStoredForFeed opens the stored copy for a client that fell behind. An
|
2026-10-27 12:30:12 +01:00
|
|
|
// unrecorded object is opened too: it is deleted only after this returns, and
|
|
|
|
|
// the client holds it from then on (missHandoff.holdUnrecorded).
|
2026-10-26 16:44:11 +01:00
|
|
|
func (p *Proxy) openStoredForFeed(ctx context.Context, a artifacts.Artifact, path string, storeErr error) (io.ReadCloser, error) {
|
|
|
|
|
if storeErr != nil && !errors.Is(storeErr, errUnrecorded) {
|
|
|
|
|
return nil, storeErr
|
|
|
|
|
}
|
|
|
|
|
res, err := p.openStoredArtifact(ctx, a, path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return res.Reader, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// copyToBoth copies src to store and to the client's feed until src ends,
|
|
|
|
|
// dropping the store once a write to it fails. It returns the error that
|
|
|
|
|
// ended the copy, nil at the end of src. Feeding the client never blocks.
|
|
|
|
|
func copyToBoth(src io.Reader, store io.Writer, client *missFeed) error {
|
2026-10-25 20:29:03 +01:00
|
|
|
buffer := artifactCopyBufferPool.Get().(*[]byte)
|
|
|
|
|
defer artifactCopyBufferPool.Put(buffer)
|
|
|
|
|
buf := *buffer
|
2026-10-26 16:44:11 +01:00
|
|
|
storeOK := true
|
2026-10-25 20:29:03 +01:00
|
|
|
for {
|
|
|
|
|
n, err := src.Read(buf)
|
|
|
|
|
if n > 0 {
|
|
|
|
|
if storeOK {
|
|
|
|
|
if _, werr := store.Write(buf[:n]); werr != nil {
|
|
|
|
|
storeOK = false
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-26 16:44:11 +01:00
|
|
|
// A client that has gone, or that waits for the stored copy,
|
|
|
|
|
// has no use for the rest once storing has failed.
|
|
|
|
|
if !client.push(buf[:n]) && !storeOK {
|
|
|
|
|
return errNoReaderLeft
|
2026-10-25 20:29:03 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if errors.Is(err, io.EOF) {
|
2026-10-26 16:44:11 +01:00
|
|
|
return nil
|
2026-10-25 20:29:03 +01:00
|
|
|
}
|
|
|
|
|
if err != nil {
|
2026-10-26 16:44:11 +01:00
|
|
|
return err
|
2026-10-25 20:29:03 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-26 16:44:11 +01:00
|
|
|
|
|
|
|
|
// clientFeedLimit bounds the bytes held for a client that reads a streamed
|
|
|
|
|
// miss slower than they arrive. A variable so tests can lower it.
|
|
|
|
|
var clientFeedLimit = 8 << 20
|
|
|
|
|
|
|
|
|
|
// missFeed is the client's side of a streamed miss. The upstream read pushes
|
|
|
|
|
// into it without blocking, so a slow client can't hold back the copy to
|
|
|
|
|
// storage, the fetch lease or the callers waiting on the fetch. Once the
|
|
|
|
|
// client is more than the limit behind, it stops being fed: it reads what
|
|
|
|
|
// is held, then continues from the stored copy where it left off.
|
|
|
|
|
type missFeed struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
cond *sync.Cond
|
|
|
|
|
chunks [][]byte
|
|
|
|
|
held int
|
|
|
|
|
limit int
|
|
|
|
|
// fed counts the bytes pushed before the client fell behind, where it
|
|
|
|
|
// continues in the stored copy.
|
|
|
|
|
fed int64
|
|
|
|
|
ended bool
|
|
|
|
|
endErr error
|
|
|
|
|
behind bool
|
|
|
|
|
closed bool
|
|
|
|
|
early bool // closed before reading everything it was fed
|
|
|
|
|
|
|
|
|
|
stored io.ReadCloser
|
|
|
|
|
storedErr error
|
|
|
|
|
resolved bool
|
|
|
|
|
skipped bool
|
2026-10-27 12:30:12 +01:00
|
|
|
// release drops the client's hold on an unrecorded stored copy.
|
|
|
|
|
release func()
|
2026-10-26 16:44:11 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newMissFeed(limit int) *missFeed {
|
|
|
|
|
m := &missFeed{limit: limit}
|
|
|
|
|
m.cond = sync.NewCond(&m.mu)
|
|
|
|
|
return m
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// push queues a copy of b and reports whether the client is still fed.
|
|
|
|
|
func (m *missFeed) push(b []byte) bool {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
if m.closed || m.behind {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if m.held+len(b) > m.limit {
|
|
|
|
|
m.behind = true
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
m.chunks = append(m.chunks, append([]byte(nil), b...))
|
|
|
|
|
m.held += len(b)
|
|
|
|
|
m.fed += int64(len(b))
|
|
|
|
|
m.cond.Broadcast()
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// end records how the upstream read ended.
|
|
|
|
|
func (m *missFeed) end(err error) {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
m.ended, m.endErr = true, err
|
|
|
|
|
m.cond.Broadcast()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *missFeed) lagging() bool {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
return m.behind && !m.closed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// fedFully reports whether the client was sent every byte from upstream.
|
|
|
|
|
func (m *missFeed) fedFully() bool {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
return !m.behind && !m.early
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// resolve gives a client that fell behind the stored copy, or the error
|
|
|
|
|
// that left none.
|
|
|
|
|
func (m *missFeed) resolve(r io.ReadCloser, err error) {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
if m.closed {
|
|
|
|
|
if r != nil {
|
|
|
|
|
_ = r.Close()
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
m.stored, m.storedErr, m.resolved = r, err, true
|
|
|
|
|
m.cond.Broadcast()
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-27 12:30:12 +01:00
|
|
|
// holdUntilClose takes a hold on u for a client reading its stored copy and
|
|
|
|
|
// releases it when the client closes.
|
|
|
|
|
func (m *missFeed) holdUntilClose(u *unrecordedObject) {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
if m.closed || m.stored == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
u.refs.Add(1)
|
|
|
|
|
m.release = u.release
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 16:44:11 +01:00
|
|
|
func (m *missFeed) Read(p []byte) (int, error) {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
for {
|
|
|
|
|
switch {
|
|
|
|
|
case m.closed:
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
return 0, io.ErrClosedPipe
|
|
|
|
|
case len(m.chunks) > 0:
|
|
|
|
|
n := copy(p, m.chunks[0])
|
|
|
|
|
if m.chunks[0] = m.chunks[0][n:]; len(m.chunks[0]) == 0 {
|
|
|
|
|
m.chunks[0] = nil
|
|
|
|
|
m.chunks = m.chunks[1:]
|
|
|
|
|
}
|
|
|
|
|
m.held -= n
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
return n, nil
|
|
|
|
|
case m.behind && m.resolved:
|
|
|
|
|
r, err, skipped := m.stored, m.storedErr, m.skipped
|
|
|
|
|
m.skipped = true
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
if !skipped {
|
|
|
|
|
if err := skipTo(r, m.fed); err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return r.Read(p)
|
|
|
|
|
case !m.behind && m.ended:
|
|
|
|
|
err := m.endErr
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
if err == nil {
|
|
|
|
|
err = io.EOF
|
|
|
|
|
}
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
m.cond.Wait()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *missFeed) Close() error {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
if m.closed {
|
2026-10-27 12:30:12 +01:00
|
|
|
m.mu.Unlock()
|
2026-10-26 16:44:11 +01:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
m.closed = true
|
|
|
|
|
m.early = !m.ended || len(m.chunks) > 0
|
|
|
|
|
m.chunks, m.held = nil, 0
|
|
|
|
|
if m.stored != nil {
|
|
|
|
|
_ = m.stored.Close()
|
|
|
|
|
}
|
2026-10-27 12:30:12 +01:00
|
|
|
release := m.release
|
|
|
|
|
m.release = nil
|
2026-10-26 16:44:11 +01:00
|
|
|
m.cond.Broadcast()
|
2026-10-27 12:30:12 +01:00
|
|
|
m.mu.Unlock()
|
|
|
|
|
if release != nil {
|
|
|
|
|
// Outside the lock: the last release deletes the object.
|
|
|
|
|
release()
|
|
|
|
|
}
|
2026-10-26 16:44:11 +01:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// skipTo moves r past the first n bytes.
|
|
|
|
|
func skipTo(r io.Reader, n int64) error {
|
|
|
|
|
if s, ok := r.(io.Seeker); ok {
|
|
|
|
|
_, err := s.Seek(n, io.SeekStart)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if _, err := io.CopyN(io.Discard, r, n); err != nil {
|
|
|
|
|
return fmt.Errorf("skipping to byte %d of the stored artifact: %w", n, err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|