GopeedLab/gopeed · warning

blob source closed

Error message

blob source closed

What it means

Returned by Source.releaseTask when taskRefs is already zero (registry.go:423-425) — i.e. Release(raw) was called more times than Acquire(raw) succeeded, or without any prior Acquire. It is a reference-counting imbalance detector. The same sentinel also surfaces (wrapped, 'blob source closed: opener returned a nil reader') from ServeHTTP when an OpenFunc returns nil, nil (registry.go:355-356).

Source

Thrown at internal/blob/registry.go:34

	"strings"
	"sync"
	"time"
)

const urlPathPrefix = "/__blob/"

const rangeSourceFailureLimit = 2

// unclaimedSourceTTL bounds how long a session-backed source may keep its
// engine alive without ever being claimed by a download task.
var unclaimedSourceTTL = 10 * time.Minute

var (
	ErrInvalidURL      = errors.New("invalid blob url")
	ErrInvalidOptions  = errors.New("invalid blob options")
	ErrSourceNotFound  = errors.New("blob source not found")
	ErrSourceRevoked   = errors.New("blob source revoked")
	ErrSourceClosed    = errors.New("blob source closed")
	ErrRangeNotAllowed = errors.New("blob range not allowed")
)

type SessionRef interface {
	Retain()
	Release()
}

type OpenRequest struct {
	Offset int64
	End    int64
}

type OpenFunc func(ctx context.Context, req OpenRequest) (io.ReadCloser, error)

type CreateOptions struct {
	ContentType string
	Size        int64

View on GitHub (pinned to 7b7327ffb3)

Solutions

  1. Audit Acquire/Release pairing: Acquire once when a task takes ownership, Release exactly once, guarded by a flag or done-channel
  2. Use errors.Is(err, blob.ErrSourceClosed) to detect over-release in logs — it is a bug in caller lifecycle logic, not a transient condition
  3. If the wrapped 'nil reader' variant appears, fix the OpenFunc to return a real error instead of (nil, nil)
  4. Consider owning the release with sync.Once or a single deferred release at the function that acquired

Example fix

// before
registry.Acquire(url)
defer registry.Release(url)
// ... later on error path:
registry.Release(url) // second release -> ErrSourceClosed

// after
if err := registry.Acquire(url); err != nil { return err }
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { _ = registry.Release(url) }) }
defer release()
Defensive patterns

Strategy: validation

Validate before calling

owned := false
acquire := func() error {
    if err := registry.Acquire(blobURL); err != nil { return err }
    owned = true
    return nil
}
release := func() error {
    if !owned { return nil } // prevents over-release
    owned = false
    return registry.Release(blobURL)
}

Try / catch

if err := registry.Release(blobURL); err != nil && errors.Is(err, blob.ErrSourceClosed) {
    // over-release bug: log it and fix the Acquire/Release pairing; do not retry
    log.Printf("blob over-release for %s", blobURL)
}

Prevention

When it happens

Trigger: Calling registry.Release for a task that never called Acquire (or whose Acquire failed); double-Release from a cleanup path plus a defer; releasing from both an error path and a normal path; an OpenFunc implementation returning (nil, nil) so HTTP GETs on the source fail with 410 Gone and SourceError reports this wrapped error.

Common situations: defer registry.Release(url) combined with an explicit Release on error; retries that re-run cleanup code; two goroutines (task + extension) both owning the release; custom openers that return nil reader on an internal edge case instead of an error.

Related errors


AI-assisted analysis of GopeedLab/gopeed@7b7327ffb3 (2026-08-16). Data as JSON: /api/errors/31d0e4a09a3f5f43. Report an issue: GitHub.