Skip to content

panic: send on closed channel in Printer.Write via LoadImage (--load) progress after solve completes #3948

Description

@fedor-rubalskiy

Summary

docker buildx build --load can crash the client with panic: send on closed channel in util/progress.(*Printer).Write when the local image load (LoadImage / "importing to docker") is still streaming progress after the build's top-level progress Printer has been closed. It is non-deterministic and only shows up on large images whose local load is slow enough to outlive the solve.

This is the same underlying defect as #3875, but on a different writer path. #3875 was closed by #3886, which reordered cleanup so the remote policy-resolver progress writer no longer outlives the printer. The LoadImage / --load writer (util/dockerutil) is not covered by that fix, and Printer.Write itself is still an unguarded channel send on master.

Version

Observed on a self-hosted CI runner using Docker's docker-buildx plugin. The racing code is unchanged on the latest release v0.35.0 and current master (verified by source inspection — see below), so this is not specific to an old version.

Stack trace

panic: send on closed channel

goroutine 1637348 [running]:
github.com/docker/buildx/util/progress.(*Printer).Write(...)
	github.com/docker/buildx/util/progress/printer.go:89 +0x26
github.com/docker/buildx/util/progress.(*pw).Write(...)
	github.com/docker/buildx/util/progress/reset.go:66 +0xa5
github.com/docker/buildx/util/progress.Wrap.func1()
	github.com/docker/buildx/util/progress/progress.go:36 +0x1be
panic({...})
	runtime/panic.go:860 +0x13a
github.com/docker/buildx/util/progress.(*Printer).Write(...)
	github.com/docker/buildx/util/progress/printer.go:89 +0x26
github.com/docker/buildx/util/progress.(*pw).Write(...)
	github.com/docker/buildx/util/progress/reset.go:66 +0xa5
github.com/docker/buildx/util/progress.(*subLogger).SetStatus(...)
	github.com/docker/buildx/util/progress/progress.go:95 +0xc2
github.com/docker/buildx/util/dockerutil.fromReader.func1()
	github.com/docker/buildx/util/dockerutil/progress.go:23 +0x1de
github.com/docker/buildx/util/dockerutil.fromReader(...)
	github.com/docker/buildx/util/dockerutil/progress.go:39 +0x7aa
github.com/docker/buildx/util/dockerutil.(*Client).LoadImage.func1.2(...)
	github.com/docker/buildx/util/dockerutil/client.go:64 +0x45
github.com/docker/buildx/util/progress.Wrap(...)
	github.com/docker/buildx/util/progress/progress.go:47 +0x297
github.com/docker/buildx/util/dockerutil.(*Client).LoadImage.func1()
	github.com/docker/buildx/util/dockerutil/client.go:63 +0x245
created by github.com/docker/buildx/util/dockerutil.(*waitingWriter).Write.func1 in goroutine 1637362
	github.com/docker/buildx/util/dockerutil/client.go:111 +0x1b

The log immediately before the panic shows the image load in progress (two multi-GB layers), which is what gives the race a wide enough window:

#57 loading layer dbd9cc758bf1 2.68GB / 2.70GB 33.0s
#57 loading layer 96431b6a72fe 777.09MB / 2.98GB 10.2s
panic: send on closed channel

Root cause

Printer.Write sends on p.status with no guard (master, util/progress/printer.go):

func (p *Printer) Write(s *client.SolveStatus) {
	p.status <- s
	...
}

func (p *Printer) Wait() error {
	p.closeOnce.Do(func() {
		close(p.status)
	})
	<-p.done
	return p.err
}

closeOnce only makes the close idempotent; it does nothing to prevent a Write that races the close.

On --load, LoadImage streams "importing to docker" progress into that same Printer from a goroutine (util/dockerutil/client.go):

func (w *waitingWriter) Write(dt []byte) (int, error) {
	w.once.Do(func() {
		go w.f() // f() runs progress.Wrap("importing to docker", status.Write, ...) -> Printer.Write
	})
	return w.PipeWriter.Write(dt)
}

waitingWriter.Close() waits on its own w.done, so the load pipe is synchronized — but the goroutine writes into the build's top-level Printer, whose Wait() is called elsewhere in the build pipeline. When the local load is slow (large image), that Wait()/close(p.status) can happen while the "importing to docker" goroutine is still calling Printer.Write, and the send lands on a closed channel → panic.

This is exactly the "progress writer outlives the printer" class that #3886 fixed for the policy-resolver writer; the --load/LoadImage writer needs the same guarantee (or Printer.Write needs to stop panicking on a post-close write).

Suggested fix

Either (or both):

  1. Make Printer.Write non-fatal after close, e.g.

    func (p *Printer) Write(s *client.SolveStatus) {
        select {
        case p.status <- s:
        case <-p.done:
            return // printer already closed; drop late status
        }
        ...
    }

    This makes any late writer (LoadImage, and future callers) safe rather than fixing one caller at a time.

  2. Ensure the --load "importing to docker" goroutine is joined before the build's Printer.Wait() closes the channel, mirroring the cleanup reordering in build: fix policy resolver cleanup on errors #3886.

Impact

Non-deterministic red builds on --load of large images (multi-GB), even though the image built and every layer exported — the crash is purely in the client's progress plumbing. Happy to test a patch.

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