Skip to content

internal/gzip: ReadCloserLevel goroutine leaks r on error, causing pullLimiter deadlock via layout.WriteImage #2342

Closed

Description

@FrancisQuaisr

Summary

internal/gzip.ReadCloserLevel starts a background goroutine that holds r io.ReadCloser. In several error paths that goroutine calls pw.CloseWithError(err) without first calling r.Close(). When r wraps a limitedReadCloser (i.e. a remote layer reader acquired via the pullLimiter introduced in #2271), the token slot is permanently leaked.

This is a distinct bug from the call-site leaks fixed by #2277 and #2308 — those fixes ensure callers close the reader; this bug means the goroutine itself fails to close its copy of r when the pipe reader is closed early by the caller.

Relationship to existing issues

Trigger

layout/write.go's writeBlob exits early (without reading all data) when it detects the blob file already exists on disk:

func (l Path) writeBlob(hash v1.Hash, size int64, rc io.ReadCloser, ...) error {
    defer rc.Close()   // ← closes the pipe reader even if not fully consumed
    ...
    if s, err := os.Stat(file); err == nil && !s.IsDir() && ... {
        return nil     // ← early exit
    }
    ...
}

When rc is the pr pipe reader returned by ReadCloserLevel, closing it early causes the goroutine's next bw.Flush() → pw.Write() to return io.ErrClosedPipe. The goroutine then hits this path:

// internal/gzip/zip.go
if err := bw.Flush(); err != nil {
    return pw.CloseWithError(err)  // r.Close() is never called → token leaked
}

limitedReadCloser.Close() is never called, so the pullLimiter slot is permanently occupied.

Why this deadlocks in practice

layout.WriteImage (used by kaniko to save a build stage) calls img.Layers() and spawns one goroutine per layer. After go-containerregistry v0.21.6's change to mutate.image.Layers() (which looks up layers by compressed digest), multiple manifest entries that share the same digest — e.g. empty-tar snapshots from metadata-only Dockerfile commands — are all backed by the same layer object. Every goroutine therefore tries to write the same blob.

The first goroutine succeeds and renames the blob file. Every subsequent goroutine hits writeBlob's early-exit and leaks a token. With defaultJobs = 4, four leaked tokens fill the limiter channel and all remaining goroutines block forever in pullLimiter.acquire.

Affected code

// internal/gzip/zip.go — ReadCloserLevel goroutine
gw, err := gzip.NewWriterLevel(bw, level)
if err != nil {
    // r.Close() missing → leak
    return pw.CloseWithError(err)
}

if _, err := io.Copy(gw, r); err != nil {
    defer r.Close()   // ✓ this path is fine
    defer gw.Close()
    return pw.CloseWithError(err)
}

if err := gw.Close(); err != nil {
    // r.Close() missing → leak
    return pw.CloseWithError(err)
}

if err := bw.Flush(); err != nil {
    // r.Close() missing → leak  (most common trigger)
    return pw.CloseWithError(err)
}

Fix

--- a/internal/gzip/zip.go
+++ b/internal/gzip/zip.go
@@ -56,6 +56,7 @@ func ReadCloserLevel(r io.ReadCloser, level int) io.ReadCloser {
 		gw, err := gzip.NewWriterLevel(bw, level)
 		if err != nil {
+			r.Close()
 			return pw.CloseWithError(err)
 		}
 
@@ -68,10 +68,12 @@ func ReadCloserLevel(r io.ReadCloser, level int) io.ReadCloser {
 		// Close gzip writer to Flush it and write gzip trailers.
 		if err := gw.Close(); err != nil {
+			r.Close()
 			return pw.CloseWithError(err)
 		}
 
 		// Flush bufio writer to ensure we write out everything.
 		if err := bw.Flush(); err != nil {
+			r.Close()
 			return pw.CloseWithError(err)
 		}

Environment

  • go-containerregistry v0.21.6
  • Triggered via kaniko v1.27.6 (osscontainertools/kaniko#775) using layout.WriteImage on a cached stage

Activity

  1. FrancisQuaisr commented on Jun 9, 2026

    @FrancisQuaisr
    Author

    The issue body proposes adding r.Close() to each missing error path, but a more robust fix is to defer r.Close() at the top of the goroutine — the same approach the existing TODO(go1.14) comment was pointing toward. This way any future error path added to the function is automatically covered, rather than relying on every contributor remembering to add the manual call.

    The pw close requires a named error variable to distinguish success from failure in the defer:

    --- a/internal/gzip/zip.go
    +++ b/internal/gzip/zip.go
    @@ -49,36 +49,33 @@ func ReadCloserLevel(r io.ReadCloser, level int) io.ReadCloser {
     	// 64K ought to be small enough for anybody.
     	bw := bufio.NewWriterSize(pw, 2<<16)
     
    -	// Returns err so we can pw.CloseWithError(err)
    -	go func() error {
    -		// TODO(go1.14): Just defer {pw,gw,r}.Close like you'd expect.
    -		// Context: https://golang.org/issue/24283
    -		gw, err := gzip.NewWriterLevel(bw, level)
    -		if err != nil {
    -			return pw.CloseWithError(err)
    +	// Defer r.Close() and pw at the top so all exit paths are covered.
    +	// The previous implementation called r.Close() manually per error path
    +	// and missed some, leaking any pullLimiter token held by r.
    +	go func() {
    +		var err error
    +		defer r.Close()
    +		defer func() {
    +			if err != nil {
    +				pw.CloseWithError(err)
    +			} else {
    +				pw.Close()
    +			}
    +		}()
    +
    +		var gw *gzip.Writer
    +		if gw, err = gzip.NewWriterLevel(bw, level); err != nil {
    +			return
     		}
    -
    -		if _, err := io.Copy(gw, r); err != nil {
    -			defer r.Close()
    -			defer gw.Close()
    -			return pw.CloseWithError(err)
    +		if _, err = io.Copy(gw, r); err != nil {
    +			return
     		}
    -
     		// Close gzip writer to Flush it and write gzip trailers.
    -		if err := gw.Close(); err != nil {
    -			return pw.CloseWithError(err)
    +		if err = gw.Close(); err != nil {
    +			return
     		}
    -
     		// Flush bufio writer to ensure we write out everything.
    -		if err := bw.Flush(); err != nil {
    -			return pw.CloseWithError(err)
    -		}
    -
    -		// We don't really care if these fail.
    -		defer pw.Close()
    -		defer r.Close()
    -
    -		return nil
    +		err = bw.Flush()
     	}()
     
     	return pr
  2. added a commit that references this issue on Jun 17, 2026
    7ad33fb
  3. added a commit that references this issue on Jun 17, 2026
    e963497
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions