Repository: docker/buildx

Issue:
Expand buildx exit codes to include buildkit-specific error classifications
### Description

## Motivation
Currently, buildx doesn't provide granular exit codes that correspond to specific error types from buildkit. This makes it difficult to programmatically determine the exact nature of a failure, especially in CI/CD environments where automatic retry or fallback logic might depend on the specific type of error.

## Proposal
Expand the buildx exit code system to map buildkit's error classifications to unique exit codes. As an initial implementation, focus on the "internal" errors defined in the [moby/buildkit errdef package](https://github.com/moby/buildkit/tree/f2f3719ec0c5d4d9b7aba0e8bd9bd9bdce7b4024/errdefs).

## Follow-on work
- Expand codes to cover additional error categories/cases
~~- GHA handling~~


Hints from issue comments:
@tonistiigi: > As an initial implementation, focus on the "internal" errors defined in the [moby/buildkit errdef package](https://github.com/moby/buildkit/tree/f2f3719ec0c5d4d9b7aba0e8bd9bd9bdce7b4024/errdefs).

What exit code do you want?

> GHA handling

wdym?@thaJeztah: Some similar ticket; 

- https://github.com/docker/cli/issues/1683

I'm not sure if it'll be very useful / easy to make such exit-codes actionable though; there's a limited number of exit-codes that _could_ be used, but without context, it may be hard to programmatically decide whether they can be retried (or otherwise), as many things could depend on context (if a command in a build-step fails, can it be retried? _which command failed_?).@thompson-shaun: > What exit code do you want?

Likely a block that we could expand with that's not in conventional/reserved ranges so smth in the 80-99 range or 100-126 (whatever you feel is most appropriate) [ref](https://pkg.go.dev/github.com/square/exit#pkg-types). @thaJeztah has a nice breakdown of ranges in https://github.com/docker/buildx/issues/3174#issuecomment-2867490072:~:text=%5BRFC%5D%20standardise%20exit%2Dcodes%C2%A0cli%231683 as well. 

> 
> > GHA handling
> 
> wdym?

Scrapped this

> I'm not sure if it'll be very useful / easy to make such exit-codes actionable though; there's a limited number of exit-codes that could be used, but without context, it may be hard to programmatically decide whether they can be retried (or otherwise), as many things could depend on context (if a command in a build-step fails, can it be retried? which command failed?).

Totally fair! The hope was to "bin" the error in a way not dissimilar to [grpc error codes](https://grpc.io/docs/guides/error/#protocol-errors) to provide a general "feel" for the error with more details provided by another mechanism.@tonistiigi: @thompson-shaun So to confirm, you want `errdefs.Internal` to return 100 ?@thompson-shaun: Sure, I think that's a good a choice as any. As long as we're staying out of known used values and taking [prior art](https://github.com/square/exit#reserved-codes-and-prior-art) into account. @thaJeztah's [comment](https://github.com/docker/cli/issues/1683#issuecomment-2715804717) had some additional ideas.

Solution patch:
diff --git a/bake/hclparser/hclparser.go b/bake/hclparser/hclparser.go
index d8f13459..0cf38e14 100644
--- a/bake/hclparser/hclparser.go
+++ b/bake/hclparser/hclparser.go
@@ -38,6 +38,9 @@ type variable struct {
 	Validations []*variableValidation `json:"validation,omitempty" hcl:"validation,block"`
 	Body        hcl.Body              `json:"-" hcl:",body"`
 	Remain      hcl.Body              `json:"-" hcl:",remain"`
+
+	// the type described by Type if it was specified
+	constraint *cty.Type
 }
 
 type variableValidation struct {
@@ -296,6 +299,9 @@ func (p *parser) resolveValue(ectx *hcl.EvalContext, name string) (err error) {
 			return diags
 		}
 		typeSpecified = !varType.Equals(cty.DynamicPseudoType) || hcl.ExprAsKeyword(vr.Type) == "any"
+		if typeSpecified {
+			vr.constraint = &varType
+		}
 	}
 
 	if def == nil {
@@ -674,6 +680,7 @@ func (p *parser) validateVariables(vars map[string]*variable, ectx *hcl.EvalCont
 type Variable struct {
 	Name        string  `json:"name"`
 	Description string  `json:"description,omitempty"`
+	Type        string  `json:"type,omitempty"`
 	Value       *string `json:"value,omitempty"`
 }
 
@@ -803,13 +810,31 @@ func Parse(b hcl.Body, opt Opt, val any) (*ParseMeta, hcl.Diagnostics) {
 			Name:        p.vars[k].Name,
 			Description: p.vars[k].Description,
 		}
+		tc := p.vars[k].constraint
+		if tc != nil {
+			v.Type = tc.FriendlyNameForConstraint()
+		}
 		if vv := p.ectx.Variables[k]; !vv.IsNull() {
 			var s string
-			switch vv.Type() {
-			case cty.String:
-				s = vv.AsString()
-			case cty.Bool:
-				s = strconv.FormatBool(vv.True())
+			switch {
+			case tc != nil:
+				if bs, err := ctyjson.Marshal(vv, *tc); err == nil {
+					s = string(bs)
+					// untyped strings were always unquoted, so be consistent with typed strings as well
+					if tc.Equals(cty.String) {
+						s = strings.Trim(s, "\"")
+					}
+				}
+			case vv.Type().IsPrimitiveType():
+				// all primitives can convert to string, so error should never occur
+				if val, err := convert.Convert(vv, cty.String); err == nil {
+					s = val.AsString()
+				}
+			default:
+				// must be an (inferred) tuple or object
+				if bs, err := ctyjson.Marshal(vv, vv.Type()); err == nil {
+					s = string(bs)
+				}
 			}
 			v.Value = &s
 		}
diff --git a/build/build.go b/build/build.go
index 0f8fca6d..a8f69128 100644
--- a/build/build.go
+++ b/build/build.go
@@ -138,75 +138,68 @@ func filterAvailableNodes(nodes []builder.Node) ([]builder.Node, error) {
 	return nil, err
 }
 
-func toRepoOnly(in string) (string, error) {
-	m := map[string]struct{}{}
-	p := strings.Split(in, ",")
-	for _, pp := range p {
-		n, err := reference.ParseNormalizedNamed(pp)
-		if err != nil {
-			return "", err
+// findNonMobyDriver returns the first non-moby based driver.
+func findNonMobyDriver(nodes []builder.Node) *driver.DriverHandle {
+	for _, n := range nodes {
+		if !n.Driver.IsMobyDriver() {
+			return n.Driver
 		}
-		m[n.Name()] = struct{}{}
-	}
-	out := make([]string, 0, len(m))
-	for k := range m {
-		out = append(out, k)
 	}
-	return strings.Join(out, ","), nil
-}
-
-func Build(ctx context.Context, nodes []builder.Node, opts map[string]Options, docker *dockerutil.Client, cfg *confutil.Config, w progress.Writer) (resp map[string]*client.SolveResponse, err error) {
-	return BuildWithResultHandler(ctx, nodes, opts, docker, cfg, w, nil)
+	return nil
 }
 
-func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[string]Options, docker *dockerutil.Client, cfg *confutil.Config, w progress.Writer, resultHandleFunc func(driverIndex int, rCtx *ResultHandle)) (resp map[string]*client.SolveResponse, err error) {
-	if len(nodes) == 0 {
-		return nil, errors.Errorf("driver required for build")
+// warnOnNoOutput will check if the given nodes and options would result in an output
+// and prints a warning if it would not.
+func warnOnNoOutput(ctx context.Context, nodes []builder.Node, opts map[string]Options) {
+	// Return immediately if default load is explicitly disabled or a call
+	// function is used.
+	if noDefaultLoad() || !noCallFunc(opts) {
+		return
 	}
 
-	nodes, err = filterAvailableNodes(nodes)
-	if err != nil {
-		return nil, errors.Wrapf(err, "no valid drivers found")
+	// Find the first non-moby driver and return if it either doesn't exist
+	// or if the driver has default load enabled.
+	noMobyDriver := findNonMobyDriver(nodes)
+	if noMobyDriver == nil || noMobyDriver.Features(ctx)[driver.DefaultLoad] {
+		return
 	}
 
-	var noMobyDriver *driver.DriverHandle
-	for _, n := range nodes {
-		if !n.Driver.IsMobyDriver() {
-			noMobyDriver = n.Driver
-			break
+	// Produce a warning describing the targets affected.
+	var noOutputTargets []string
+	for name, opt := range opts {
+		if !opt.Linked && len(opt.Exports) == 0 {
+			noOutputTargets = append(noOutputTargets, name)
 		}
 	}
 
-	if noMobyDriver != nil && !noDefaultLoad() && noCallFunc(opts) {
-		var noOutputTargets []string
-		for name, opt := range opts {
-			if noMobyDriver.Features(ctx)[driver.DefaultLoad] {
-				continue
-			}
-
-			if !opt.Linked && len(opt.Exports) == 0 {
-				noOutputTargets = append(noOutputTargets, name)
-			}
-		}
-		if len(noOutputTargets) > 0 {
-			var warnNoOutputBuf bytes.Buffer
-			warnNoOutputBuf.WriteString("No output specified ")
-			if len(noOutputTargets) == 1 && noOutputTargets[0] == "default" {
-				warnNoOutputBuf.WriteString(fmt.Sprintf("with %s driver", noMobyDriver.Factory().Name()))
-			} else {
-				warnNoOutputBuf.WriteString(fmt.Sprintf("for %s target(s) with %s driver", strings.Join(noOutputTargets, ", "), noMobyDriver.Factory().Name()))
-			}
-			logrus.Warnf("%s. Build result will only remain in the build cache. To push result image into registry use --push or to load image into docker use --load", warnNoOutputBuf.String())
-		}
+	if len(noOutputTargets) == 0 {
+		return
 	}
 
-	drivers, err := resolveDrivers(ctx, nodes, opts, w)
-	if err != nil {
-		return nil, err
+	var warnNoOutputBuf bytes.Buffer
+	warnNoOutputBuf.WriteString("No output specified ")
+	if len(noOutputTargets) == 1 && noOutputTargets[0] == "default" {
+		warnNoOutputBuf.WriteString(fmt.Sprintf("with %s driver", noMobyDriver.Factory().Name()))
+	} else {
+		warnNoOutputBuf.WriteString(fmt.Sprintf("for %s target(s) with %s driver", strings.Join(noOutputTargets, ", "), noMobyDriver.Factory().Name()))
 	}
+	logrus.Warnf("%s. Build result will only remain in the build cache. To push result image into registry use --push or to load image into docker use --load", warnNoOutputBuf.String())
+}
 
+func newBuildRequests(ctx context.Context, docker *dockerutil.Client, cfg *confutil.Config, drivers map[string][]*resolvedNode, w progress.Writer, opts map[string]Options) (_ map[string][]*reqForNode, _ func(), retErr error) {
 	reqForNodes := make(map[string][]*reqForNode)
-	eg, ctx := errgroup.WithContext(ctx)
+
+	var releasers []func()
+	releaseAll := func() {
+		for _, fn := range releasers {
+			fn()
+		}
+	}
+	defer func() {
+		if retErr != nil {
+			releaseAll()
+		}
+	}()
 
 	for k, opt := range opts {
 		multiDriver := len(drivers[k]) > 1
@@ -226,17 +219,17 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
 			opt.Platforms = np.platforms
 			gatewayOpts, err := np.BuildOpts(ctx)
 			if err != nil {
-				return nil, err
+				return nil, nil, err
 			}
 			localOpt := opt
 			so, release, err := toSolveOpt(ctx, np.Node(), multiDriver, &localOpt, gatewayOpts, cfg, w, docker)
 			opts[k] = localOpt
 			if err != nil {
-				return nil, err
+				return nil, nil, err
 			}
-			defer release()
+			releasers = append(releasers, release)
 			if err := saveLocalState(so, k, opt, np.Node(), cfg); err != nil {
-				return nil, err
+				return nil, nil, err
 			}
 			addGitAttrs(so)
 			reqn = append(reqn, &reqForNode{
@@ -261,15 +254,17 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
 				for _, e := range np.so.Exports {
 					if e.Type == "moby" {
 						if ok, _ := strconv.ParseBool(e.Attrs["push"]); ok {
-							return nil, errors.Errorf("multi-node push can't currently be performed with the docker driver, please switch to a different driver")
+							return nil, nil, errors.Errorf("multi-node push can't currently be performed with the docker driver, please switch to a different driver")
 						}
 					}
 				}
 			}
 		}
 	}
+	return reqForNodes, releaseAll, nil
+}
 
-	// validate that all links between targets use same drivers
+func validateTargetLinks(reqForNodes map[string][]*reqForNode, drivers map[string][]*resolvedNode, opts map[string]Options) error {
 	for name := range opts {
 		dps := reqForNodes[name]
 		for i, dp := range dps {
@@ -279,8 +274,9 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
 					k2 := strings.TrimPrefix(v, "target:")
 					dps2, ok := drivers[k2]
 					if !ok {
-						return nil, errors.Errorf("failed to find target %s for context %s", k2, strings.TrimPrefix(k, "context:")) // should be validated before already
+						return errors.Errorf("failed to find target %s for context %s", k2, strings.TrimPrefix(k, "context:")) // should be validated before already
 					}
+
 					var found bool
 					for _, dp2 := range dps2 {
 						if dp2.driverIndex == dp.driverIndex {
@@ -289,12 +285,67 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
 						}
 					}
 					if !found {
-						return nil, errors.Errorf("failed to use %s as context %s for %s because targets build with different drivers", k2, strings.TrimPrefix(k, "context:"), name)
+						return errors.Errorf("failed to use %s as context %s for %s because targets build with different drivers", k2, strings.TrimPrefix(k, "context:"), name)
 					}
 				}
 			}
 		}
 	}
+	return nil
+}
+
+func toRepoOnly(in string) (string, error) {
+	m := map[string]struct{}{}
+	p := strings.Split(in, ",")
+	for _, pp := range p {
+		n, err := reference.ParseNormalizedNamed(pp)
+		if err != nil {
+			return "", err
+		}
+		m[n.Name()] = struct{}{}
+	}
+	out := make([]string, 0, len(m))
+	for k := range m {
+		out = append(out, k)
+	}
+	return strings.Join(out, ","), nil
+}
+
+type Handler struct {
+	OnResult func(driverIdx int, rCtx *ResultHandle)
+}
+
+func Build(ctx context.Context, nodes []builder.Node, opts map[string]Options, docker *dockerutil.Client, cfg *confutil.Config, w progress.Writer) (resp map[string]*client.SolveResponse, err error) {
+	return BuildWithResultHandler(ctx, nodes, opts, docker, cfg, w, nil)
+}
+
+func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[string]Options, docker *dockerutil.Client, cfg *confutil.Config, w progress.Writer, bh *Handler) (resp map[string]*client.SolveResponse, err error) {
+	if len(nodes) == 0 {
+		return nil, errors.Errorf("driver required for build")
+	}
+
+	nodes, err = filterAvailableNodes(nodes)
+	if err != nil {
+		return nil, errors.Wrapf(err, "no valid drivers found")
+	}
+	warnOnNoOutput(ctx, nodes, opts)
+
+	drivers, err := resolveDrivers(ctx, nodes, opts, w)
+	if err != nil {
+		return nil, err
+	}
+
+	eg, ctx := errgroup.WithContext(ctx)
+	reqForNodes, release, err := newBuildRequests(ctx, docker, cfg, drivers, w, opts)
+	if err != nil {
+		return nil, err
+	}
+	defer release()
+
+	// validate that all links between targets use same drivers
+	if err := validateTargetLinks(reqForNodes, drivers, opts); err != nil {
+		return nil, err
+	}
 
 	sharedSessions, err := detectSharedMounts(ctx, reqForNodes)
 	if err != nil {
@@ -311,7 +362,6 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
 
 	for k, opt := range opts {
 		err := func(k string) (err error) {
-			opt := opt
 			dps := drivers[k]
 			multiDriver := len(drivers[k]) > 1
 
@@ -441,19 +491,11 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
 							req.FrontendOpt["requestid"] = "frontend." + opt.CallFunc.Name
 						}
 
-						res, err := c.Solve(ctx, req)
+						res, err := solve(ctx, c, req)
 						if err != nil {
-							req, ok := fallbackPrintError(err, req)
-							if ok {
-								res2, err2 := c.Solve(ctx, req)
-								if err2 != nil {
-									return nil, err
-								}
-								res = res2
-							} else {
-								return nil, err
-							}
+							return nil, err
 						}
+
 						if opt.CallFunc != nil {
 							callRes = res.Metadata
 						}
@@ -461,41 +503,26 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opts map[
 						rKey := resultKey(dp.driverIndex, k)
 						results.Set(rKey, res)
 
-						if children, ok := childTargets[rKey]; ok && len(children) > 0 {
-							// wait for the child targets to register their LLB before evaluating
-							_, err := results.Get(ctx, children...)
-							if err != nil {
-								return nil, err
-							}
-							// we need to wait until the child targets have completed before we can release
-							eg, ctx := errgroup.WithContext(ctx)
-							eg.Go(func() error {
-								return res.EachRef(func(ref gateway.Reference) error {
-									return ref.Evaluate(ctx)
-								})
-							})
-							eg.Go(func() error {
-								_, err := results.Get(ctx, children...)
-								return err
-							})
-							if err := eg.Wait(); err != nil {
+						if children := childTargets[rKey]; len(children) > 0 {
+							if err := waitForChildren(ctx, res, results, children); err != nil {
 								return nil, err
 							}
 						}
-
 						return res, nil
 					}
 					buildRef := fmt.Sprintf("%s/%s/%s", node.Builder, node.Name, so.Ref)
+
 					var rr *client.SolveResponse
-					if resultHandleFunc != nil {
+					if bh != nil && bh.OnResult != nil {
 						var resultHandle *ResultHandle
 						resultHandle, rr, err = NewResultHandle(ctx, cc, *so, "buildx", buildFunc, ch)
-						resultHandleFunc(dp.driverIndex, resultHandle)
+						bh.OnResult(dp.driverIndex, resultHandle)
 					} else {
 						span, ctx := tracing.StartSpan(ctx, "build")
 						rr, err = c.Build(ctx, *so, "buildx", buildFunc, ch)
 						tracing.FinishWithError(span, err)
 					}
+
 					if !so.Internal && desktop.BuildBackendEnabled() && node.Driver.HistoryAPISupported(ctx) {
 						if err != nil {
 							return &desktop.ErrorWithBuildRef{
@@ -1146,3 +1173,40 @@ func ReadSourcePolicy() (*spb.Policy, error) {
 
 	return &pol, nil
 }
+
+func solve(ctx context.Context, c gateway.Client, req gateway.SolveRequest) (*gateway.Result, error) {
+	res, err := c.Solve(ctx, req)
+	if err != nil {
+		req, ok := fallbackPrintError(err, req)
+		if ok {
+			res2, err2 := c.Solve(ctx, req)
+			if err2 != nil {
+				return nil, err
+			}
+			res = res2
+		} else {
+			return nil, err
+		}
+	}
+	return res, nil
+}
+
+func waitForChildren(ctx context.Context, res *gateway.Result, results *waitmap.Map, children []string) error {
+	// wait for the child targets to register their LLB before evaluating
+	_, err := results.Get(ctx, children...)
+	if err != nil {
+		return err
+	}
+	// we need to wait until the child targets have completed before we can release
+	eg, ctx := errgroup.WithContext(ctx)
+	eg.Go(func() error {
+		return res.EachRef(func(ref gateway.Reference) error {
+			return ref.Evaluate(ctx)
+		})
+	})
+	eg.Go(func() error {
+		_, err := results.Get(ctx, children...)
+		return err
+	})
+	return eg.Wait()
+}
diff --git a/cmd/buildx/main.go b/cmd/buildx/main.go
index 381846e4..078adac4 100644
--- a/cmd/buildx/main.go
+++ b/cmd/buildx/main.go
@@ -14,10 +14,12 @@ import (
 	"github.com/docker/cli/cli-plugins/plugin"
 	"github.com/docker/cli/cli/command"
 	"github.com/docker/cli/cli/debug"
-	"github.com/moby/buildkit/solver/errdefs"
+	solvererrdefs "github.com/moby/buildkit/solver/errdefs"
+	"github.com/moby/buildkit/util/grpcerrors"
 	"github.com/moby/buildkit/util/stack"
 	"github.com/pkg/errors"
 	"go.opentelemetry.io/otel"
+	"google.golang.org/grpc/codes"
 
 	_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
 
@@ -99,7 +101,7 @@ func main() {
 		os.Exit(sterr.StatusCode)
 	}
 
-	for _, s := range errdefs.Sources(err) {
+	for _, s := range solvererrdefs.Sources(err) {
 		s.Print(cmd.Err())
 	}
 	if debug.IsEnabled() {
@@ -113,5 +115,19 @@ func main() {
 		ebr.Print(cmd.Err())
 	}
 
-	os.Exit(1)
+	exitCode := 1
+	switch grpcerrors.Code(err) {
+	case codes.Internal:
+		exitCode = 100 // https://github.com/square/exit/blob/v1.3.0/exit.go#L70
+	case codes.ResourceExhausted:
+		exitCode = 102
+	case codes.Canceled:
+		exitCode = 130
+	default:
+		if errors.Is(err, context.Canceled) {
+			exitCode = 130
+		}
+	}
+
+	os.Exit(exitCode)
 }
diff --git a/commands/bake.go b/commands/bake.go
index e9636e2f..24e77761 100644
--- a/commands/bake.go
+++ b/commands/bake.go
@@ -663,7 +663,7 @@ func printVars(w io.Writer, format string, vars []*hclparser.Variable) error {
 	tw := tabwriter.NewWriter(w, 1, 8, 1, '\t', 0)
 	defer tw.Flush()
 
-	tw.Write([]byte("VARIABLE\tVALUE\tDESCRIPTION\n"))
+	tw.Write([]byte("VARIABLE\tTYPE\tVALUE\tDESCRIPTION\n"))
 
 	for _, v := range vars {
 		var value string
@@ -672,7 +672,7 @@ func printVars(w io.Writer, format string, vars []*hclparser.Variable) error {
 		} else {
 			value = "<null>"
 		}
-		fmt.Fprintf(tw, "%s\t%s\t%s\n", v.Name, value, v.Description)
+		fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", v.Name, v.Type, value, v.Description)
 	}
 	return nil
 }
diff --git a/commands/build.go b/commands/build.go
index 7c902758..60062a9a 100644
--- a/commands/build.go
+++ b/commands/build.go
@@ -21,9 +21,7 @@ import (
 	"github.com/docker/buildx/build"
 	"github.com/docker/buildx/builder"
 	"github.com/docker/buildx/commands/debug"
-	"github.com/docker/buildx/controller"
 	cbuild "github.com/docker/buildx/controller/build"
-	"github.com/docker/buildx/controller/control"
 	controllererrors "github.com/docker/buildx/controller/errdefs"
 	controllerapi "github.com/docker/buildx/controller/pb"
 	"github.com/docker/buildx/monitor"
@@ -33,7 +31,6 @@ import (
 	"github.com/docker/buildx/util/cobrautil"
 	"github.com/docker/buildx/util/confutil"
 	"github.com/docker/buildx/util/desktop"
-	"github.com/docker/buildx/util/ioset"
 	"github.com/docker/buildx/util/metricutil"
 	"github.com/docker/buildx/util/osutil"
 	"github.com/docker/buildx/util/progress"
@@ -419,117 +416,53 @@ func getImageID(resp map[string]string) string {
 }
 
 func runBasicBuild(ctx context.Context, dockerCli command.Cli, opts *cbuild.Options, printer *progress.Printer) (*client.SolveResponse, *build.Inputs, error) {
-	resp, res, dfmap, err := cbuild.RunBuild(ctx, dockerCli, opts, dockerCli.In(), printer, false)
-	if res != nil {
-		res.Done()
-	}
-	return resp, dfmap, err
+	return cbuild.RunBuild(ctx, dockerCli, opts, dockerCli.In(), printer, nil)
 }
 
-func runControllerBuild(ctx context.Context, dockerCli command.Cli, opts *cbuild.Options, options buildOptions, printer *progress.Printer) (*client.SolveResponse, *build.Inputs, error) {
+func runControllerBuild(ctx context.Context, dockerCli command.Cli, opts *cbuild.Options, options buildOptions, printer *progress.Printer) (_ *client.SolveResponse, _ *build.Inputs, retErr error) {
 	if options.invokeConfig != nil && (options.dockerfileName == "-" || options.contextPath == "-") {
 		// stdin must be usable for monitor
 		return nil, nil, errors.Errorf("Dockerfile or context from stdin is not supported with invoke")
 	}
-	c := controller.NewController(ctx, dockerCli)
-	defer func() {
-		if err := c.Close(); err != nil {
-			logrus.Warnf("failed to close server connection %v", err)
-		}
-	}()
-
-	// NOTE: buildx server has the current working directory different from the client
-	// so we need to resolve paths to abosolute ones in the client.
-	opts, err := cbuild.ResolveOptionPaths(opts)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	var ref string
-	var retErr error
-	var resp *client.SolveResponse
-	var inputs *build.Inputs
 
-	var f *ioset.SingleForwarder
-	var pr io.ReadCloser
-	var pw io.WriteCloser
+	var (
+		in io.ReadCloser
+		m  *monitor.Monitor
+		bh build.Handler
+	)
 	if options.invokeConfig == nil {
-		pr = dockerCli.In()
+		in = dockerCli.In()
 	} else {
-		f = ioset.NewSingleForwarder()
-		f.SetReader(dockerCli.In())
-		pr, pw = io.Pipe()
-		f.SetWriter(pw, func() io.WriteCloser {
-			pw.Close() // propagate EOF
-			logrus.Debug("propagating stdin close")
-			return nil
-		})
-	}
+		m = monitor.New(&options.invokeConfig.InvokeConfig, dockerCli.In(), os.Stdout, os.Stderr, printer)
+		defer m.Close()
 
-	resp, inputs, err = c.Build(ctx, opts, pr, printer)
-	if err != nil {
-		var be *controllererrors.BuildError
-		if errors.As(err, &be) {
-			retErr = err
-			// We can proceed to monitor
-		} else {
-			return nil, nil, errors.Wrapf(err, "failed to build")
-		}
+		bh = m.Handler()
 	}
 
-	if options.invokeConfig != nil {
-		if err := pw.Close(); err != nil {
-			logrus.Debug("failed to close stdin pipe writer")
-		}
-		if err := pr.Close(); err != nil {
-			logrus.Debug("failed to close stdin pipe reader")
-		}
-	}
-
-	if options.invokeConfig != nil && options.invokeConfig.needsDebug(retErr) {
-		// Print errors before launching monitor
-		if err := printError(retErr, printer); err != nil {
-			logrus.Warnf("failed to print error information: %v", err)
-		}
-
-		pr2, pw2 := io.Pipe()
-		f.SetWriter(pw2, func() io.WriteCloser {
-			pw2.Close() // propagate EOF
-			return nil
-		})
-		monitorBuildResult, err := options.invokeConfig.runDebug(ctx, ref, opts, c, pr2, os.Stdout, os.Stderr, printer)
-		if err := pw2.Close(); err != nil {
-			logrus.Debug("failed to close monitor stdin pipe reader")
-		}
+	for {
+		resp, inputs, err := cbuild.RunBuild(ctx, dockerCli, opts, in, printer, &bh)
 		if err != nil {
-			logrus.Warnf("failed to run monitor: %v", err)
-		}
-		if monitorBuildResult != nil {
-			// Update return values with the last build result from monitor
-			resp, retErr = monitorBuildResult.Resp, monitorBuildResult.Err
-		}
-	} else {
-		if err := c.Close(); err != nil {
-			logrus.Warnf("close error: %v", err)
+			var be *controllererrors.BuildError
+			if errors.As(err, &be) {
+				retErr = err
+				// We can proceed to monitor
+			} else {
+				return nil, nil, errors.Wrapf(err, "failed to build")
+			}
 		}
-	}
 
-	return resp, inputs, retErr
-}
+		if m != nil {
+			if err := m.Run(ctx, err); err != nil {
+				if errors.Is(err, monitor.ErrReload) {
+					retErr = nil
+					continue
+				}
+				logrus.Warnf("failed to run monitor: %v", err)
+			}
+		}
 
-func printError(err error, printer *progress.Printer) error {
-	if err == nil {
-		return nil
-	}
-	if err := printer.Pause(); err != nil {
-		return err
+		return resp, inputs, err
 	}
-	defer printer.Unpause()
-	for _, s := range errdefs.Sources(err) {
-		s.Print(os.Stderr)
-	}
-	fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
-	return nil
 }
 
 func newDebuggableBuild(dockerCli command.Cli, rootOpts *rootOptions) debug.DebuggableCmd {
@@ -988,36 +921,21 @@ func printValue(w io.Writer, printer callFunc, version string, format string, re
 
 type invokeConfig struct {
 	controllerapi.InvokeConfig
-	onFlag     string
 	invokeFlag string
 }
 
-func (cfg *invokeConfig) needsDebug(retErr error) bool {
-	switch cfg.onFlag {
+func (cfg *invokeConfig) parseInvokeConfig(invoke, on string) error {
+	switch on {
 	case "always":
-		return true
+		cfg.SuspendOn = controllerapi.SuspendAlways
 	case "error":
-		return retErr != nil
+		cfg.SuspendOn = controllerapi.SuspendError
 	default:
-		return cfg.invokeFlag != ""
-	}
-}
-
-func (cfg *invokeConfig) runDebug(ctx context.Context, ref string, options *cbuild.Options, c control.BuildxController, stdin io.ReadCloser, stdout io.WriteCloser, stderr console.File, progress *progress.Printer) (*monitor.MonitorBuildResult, error) {
-	con := console.Current()
-	if err := con.SetRaw(); err != nil {
-		// TODO: run disconnect in build command (on error case)
-		if err := c.Close(); err != nil {
-			logrus.Warnf("close error: %v", err)
+		if invoke != "" {
+			cfg.SuspendOn = controllerapi.SuspendAlways
 		}
-		return nil, errors.Errorf("failed to configure terminal: %v", err)
 	}
-	defer con.Reset()
-	return monitor.RunMonitor(ctx, ref, options, &cfg.InvokeConfig, c, stdin, stdout, stderr, progress)
-}
 
-func (cfg *invokeConfig) parseInvokeConfig(invoke, on string) error {
-	cfg.onFlag = on
 	cfg.invokeFlag = invoke
 	cfg.Tty = true
 	cfg.NoCmd = true
diff --git a/commands/debug/root.go b/commands/debug/root.go
index f63bde3a..8ddc0df1 100644
--- a/commands/debug/root.go
+++ b/commands/debug/root.go
@@ -1,19 +1,8 @@
 package debug
 
 import (
-	"context"
-	"os"
-
-	"github.com/containerd/console"
-	"github.com/docker/buildx/controller"
-	controllerapi "github.com/docker/buildx/controller/pb"
-	"github.com/docker/buildx/monitor"
 	"github.com/docker/buildx/util/cobrautil"
-	"github.com/docker/buildx/util/progress"
 	"github.com/docker/cli/cli/command"
-	"github.com/moby/buildkit/util/progress/progressui"
-	"github.com/pkg/errors"
-	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 )
 
@@ -39,31 +28,6 @@ func RootCmd(dockerCli command.Cli, children ...DebuggableCmd) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "debug",
 		Short: "Start debugger",
-		Args:  cobra.NoArgs,
-		RunE: func(cmd *cobra.Command, args []string) error {
-			printer, err := progress.NewPrinter(context.TODO(), os.Stderr, progressui.DisplayMode(progressMode))
-			if err != nil {
-				return err
-			}
-
-			ctx := context.TODO()
-			c := controller.NewController(ctx, dockerCli)
-			defer func() {
-				if err := c.Close(); err != nil {
-					logrus.Warnf("failed to close server connection %v", err)
-				}
-			}()
-			con := console.Current()
-			if err := con.SetRaw(); err != nil {
-				return errors.Errorf("failed to configure terminal: %v", err)
-			}
-
-			_, err = monitor.RunMonitor(ctx, "", nil, &controllerapi.InvokeConfig{
-				Tty: true,
-			}, c, dockerCli.In(), os.Stdout, os.Stderr, printer)
-			con.Reset()
-			return err
-		},
 	}
 	cobrautil.MarkCommandExperimental(cmd)
 
diff --git a/commands/prune.go b/commands/prune.go
index 87f1184b..d739fac5 100644
--- a/commands/prune.go
+++ b/commands/prune.go
@@ -182,7 +182,7 @@ func pruneCmd(dockerCli command.Cli, rootOpts *rootOptions) *cobra.Command {
 	flags.BoolVarP(&options.force, "force", "f", false, "Do not prompt for confirmation")
 
 	flags.Var(&options.reservedSpace, "keep-storage", "Amount of disk space to keep for cache")
-	flags.MarkDeprecated("keep-storage", "keep-storage flag has been changed to max-storage")
+	flags.MarkDeprecated("keep-storage", "keep-storage flag has been changed to reserved-space")
 
 	return cmd
 }
diff --git a/controller/build/build.go b/controller/build/build.go
index 4eebaabf..20793609 100644
--- a/controller/build/build.go
+++ b/controller/build/build.go
@@ -5,10 +5,10 @@ import (
 	"io"
 	"path/filepath"
 	"strings"
-	"sync"
 
 	"github.com/docker/buildx/build"
 	"github.com/docker/buildx/builder"
+	"github.com/docker/buildx/controller/errdefs"
 	controllerapi "github.com/docker/buildx/controller/pb"
 	"github.com/docker/buildx/store"
 	"github.com/docker/buildx/store/storeutil"
@@ -34,9 +34,9 @@ const defaultTargetName = "default"
 // NOTE: When an error happens during the build and this function acquires the debuggable *build.ResultHandle,
 // this function returns it in addition to the error (i.e. it does "return nil, res, err"). The caller can
 // inspect the result and debug the cause of that error.
-func RunBuild(ctx context.Context, dockerCli command.Cli, in *Options, inStream io.Reader, progress progress.Writer, generateResult bool) (*client.SolveResponse, *build.ResultHandle, *build.Inputs, error) {
+func RunBuild(ctx context.Context, dockerCli command.Cli, in *Options, inStream io.Reader, progress progress.Writer, bh *build.Handler) (*client.SolveResponse, *build.Inputs, error) {
 	if in.NoCache && len(in.NoCacheFilter) > 0 {
-		return nil, nil, nil, errors.Errorf("--no-cache and --no-cache-filter cannot currently be used together")
+		return nil, nil, errors.Errorf("--no-cache and --no-cache-filter cannot currently be used together")
 	}
 
 	contexts := map[string]build.NamedContext{}
@@ -70,7 +70,7 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *Options, inStream
 
 	platforms, err := platformutil.Parse(in.Platforms)
 	if err != nil {
-		return nil, nil, nil, err
+		return nil, nil, err
 	}
 	opts.Platforms = platforms
 
@@ -81,7 +81,7 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *Options, inStream
 
 	secrets, err := controllerapi.CreateSecrets(in.Secrets)
 	if err != nil {
-		return nil, nil, nil, err
+		return nil, nil, err
 	}
 	opts.Session = append(opts.Session, secrets)
 
@@ -91,13 +91,13 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *Options, inStream
 	}
 	ssh, err := controllerapi.CreateSSH(sshSpecs)
 	if err != nil {
-		return nil, nil, nil, err
+		return nil, nil, err
 	}
 	opts.Session = append(opts.Session, ssh)
 
 	outputs, _, err := controllerapi.CreateExports(in.Exports)
 	if err != nil {
-		return nil, nil, nil, err
+		return nil, nil, err
 	}
 	if in.ExportPush {
 		var pushUsed bool
@@ -136,7 +136,7 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *Options, inStream
 
 	annotations, err := buildflags.ParseAnnotations(in.Annotations)
 	if err != nil {
-		return nil, nil, nil, errors.Wrap(err, "parse annotations")
+		return nil, nil, errors.Wrap(err, "parse annotations")
 	}
 
 	for _, o := range outputs {
@@ -156,7 +156,7 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *Options, inStream
 
 	allow, err := buildflags.ParseEntitlements(in.Allow)
 	if err != nil {
-		return nil, nil, nil, err
+		return nil, nil, err
 	}
 	opts.Allow = allow
 
@@ -180,28 +180,27 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *Options, inStream
 		builder.WithContextPathHash(contextPathHash),
 	)
 	if err != nil {
-		return nil, nil, nil, err
+		return nil, nil, err
 	}
 	if err = updateLastActivity(dockerCli, b.NodeGroup); err != nil {
-		return nil, nil, nil, errors.Wrapf(err, "failed to update builder last activity time")
+		return nil, nil, errors.Wrapf(err, "failed to update builder last activity time")
 	}
 	nodes, err := b.LoadNodes(ctx)
 	if err != nil {
-		return nil, nil, nil, err
+		return nil, nil, err
 	}
 
 	var inputs *build.Inputs
 	buildOptions := map[string]build.Options{defaultTargetName: opts}
-	resp, res, err := buildTargets(ctx, dockerCli, nodes, buildOptions, progress, generateResult)
+	resp, err := buildTargets(ctx, dockerCli, nodes, buildOptions, progress, bh)
 	err = wrapBuildError(err, false)
 	if err != nil {
-		// NOTE: buildTargets can return *build.ResultHandle even on error.
-		return nil, res, nil, err
+		return nil, nil, errdefs.WrapBuild(err)
 	}
 	if i, ok := buildOptions[defaultTargetName]; ok {
 		inputs = &i.Inputs
 	}
-	return resp, res, inputs, nil
+	return resp, inputs, nil
 }
 
 // buildTargets runs the specified build and returns the result.
@@ -209,27 +208,12 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in *Options, inStream
 // NOTE: When an error happens during the build and this function acquires the debuggable *build.ResultHandle,
 // this function returns it in addition to the error (i.e. it does "return nil, res, err"). The caller can
 // inspect the result and debug the cause of that error.
-func buildTargets(ctx context.Context, dockerCli command.Cli, nodes []builder.Node, opts map[string]build.Options, progress progress.Writer, generateResult bool) (*client.SolveResponse, *build.ResultHandle, error) {
-	var res *build.ResultHandle
-	var resp map[string]*client.SolveResponse
-	var err error
-	if generateResult {
-		var mu sync.Mutex
-		var idx int
-		resp, err = build.BuildWithResultHandler(ctx, nodes, opts, dockerutil.NewClient(dockerCli), confutil.NewConfig(dockerCli), progress, func(driverIndex int, gotRes *build.ResultHandle) {
-			mu.Lock()
-			defer mu.Unlock()
-			if res == nil || driverIndex < idx {
-				idx, res = driverIndex, gotRes
-			}
-		})
-	} else {
-		resp, err = build.Build(ctx, nodes, opts, dockerutil.NewClient(dockerCli), confutil.NewConfig(dockerCli), progress)
-	}
+func buildTargets(ctx context.Context, dockerCli command.Cli, nodes []builder.Node, opts map[string]build.Options, progress progress.Writer, bh *build.Handler) (*client.SolveResponse, error) {
+	resp, err := build.BuildWithResultHandler(ctx, nodes, opts, dockerutil.NewClient(dockerCli), confutil.NewConfig(dockerCli), progress, bh)
 	if err != nil {
-		return nil, res, err
+		return nil, err
 	}
-	return resp[defaultTargetName], res, err
+	return resp[defaultTargetName], err
 }
 
 func wrapBuildError(err error, bake bool) error {
diff --git a/controller/control/controller.go b/controller/control/controller.go
deleted file mode 100644
index cc501e9b..00000000
--- a/controller/control/controller.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package control
-
-import (
-	"context"
-	"io"
-
-	"github.com/docker/buildx/build"
-	cbuild "github.com/docker/buildx/controller/build"
-	controllerapi "github.com/docker/buildx/controller/pb"
-	"github.com/docker/buildx/controller/processes"
-	"github.com/docker/buildx/util/progress"
-	"github.com/moby/buildkit/client"
-)
-
-type BuildxController interface {
-	Build(ctx context.Context, options *cbuild.Options, in io.ReadCloser, progress progress.Writer) (resp *client.SolveResponse, inputs *build.Inputs, err error)
-	// Invoke starts an IO session into the specified process.
-	// If pid doesn't match to any running processes, it starts a new process with the specified config.
-	// If there is no container running or InvokeConfig.Rollback is specified, the process will start in a newly created container.
-	// NOTE: If needed, in the future, we can split this API into three APIs (NewContainer, NewProcess and Attach).
-	Invoke(ctx context.Context, pid string, options *controllerapi.InvokeConfig, ioIn io.ReadCloser, ioOut io.WriteCloser, ioErr io.WriteCloser) error
-	Close() error
-	ListProcesses(ctx context.Context) (infos []*processes.ProcessInfo, retErr error)
-	DisconnectProcess(ctx context.Context, pid string) error
-	Inspect(ctx context.Context) *cbuild.Options
-}
diff --git a/controller/controller.go b/controller/controller.go
deleted file mode 100644
index dfa0f47d..00000000
--- a/controller/controller.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package controller
-
-import (
-	"context"
-
-	"github.com/docker/buildx/controller/control"
-	"github.com/docker/buildx/controller/local"
-	"github.com/docker/cli/cli/command"
-)
-
-func NewController(ctx context.Context, dockerCli command.Cli) control.BuildxController {
-	return local.NewLocalBuildxController(ctx, dockerCli)
-}
diff --git a/controller/local/controller.go b/controller/local/controller.go
deleted file mode 100644
index 174a002c..00000000
--- a/controller/local/controller.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package local
-
-import (
-	"context"
-	"io"
-	"sync/atomic"
-
-	"github.com/docker/buildx/build"
-	cbuild "github.com/docker/buildx/controller/build"
-	"github.com/docker/buildx/controller/control"
-	controllererrors "github.com/docker/buildx/controller/errdefs"
-	controllerapi "github.com/docker/buildx/controller/pb"
-	"github.com/docker/buildx/controller/processes"
-	"github.com/docker/buildx/util/ioset"
-	"github.com/docker/buildx/util/progress"
-	"github.com/docker/cli/cli/command"
-	"github.com/moby/buildkit/client"
-	"github.com/pkg/errors"
-)
-
-func NewLocalBuildxController(ctx context.Context, dockerCli command.Cli) control.BuildxController {
-	return &localController{
-		dockerCli: dockerCli,
-		processes: processes.NewManager(),
-	}
-}
-
-type buildConfig struct {
-	// TODO: these two structs should be merged
-	// Discussion: https://github.com/docker/buildx/pull/1640#discussion_r1113279719
-	resultCtx    *build.ResultHandle
-	buildOptions *cbuild.Options
-}
-
-type localController struct {
-	dockerCli   command.Cli
-	buildConfig buildConfig
-	processes   *processes.Manager
-
-	buildOnGoing atomic.Bool
-}
-
-func (b *localController) Build(ctx context.Context, options *cbuild.Options, in io.ReadCloser, progress progress.Writer) (*client.SolveResponse, *build.Inputs, error) {
-	if !b.buildOnGoing.CompareAndSwap(false, true) {
-		return nil, nil, errors.New("build ongoing")
-	}
-	defer b.buildOnGoing.Store(false)
-
-	resp, res, dockerfileMappings, buildErr := cbuild.RunBuild(ctx, b.dockerCli, options, in, progress, true)
-	// NOTE: RunBuild can return *build.ResultHandle even on error.
-	if res != nil {
-		b.buildConfig = buildConfig{
-			resultCtx:    res,
-			buildOptions: options,
-		}
-		if buildErr != nil {
-			buildErr = controllererrors.WrapBuild(buildErr)
-		}
-	}
-	if buildErr != nil {
-		return nil, nil, buildErr
-	}
-	return resp, dockerfileMappings, nil
-}
-
-func (b *localController) ListProcesses(ctx context.Context) (infos []*processes.ProcessInfo, retErr error) {
-	return b.processes.ListProcesses(), nil
-}
-
-func (b *localController) DisconnectProcess(ctx context.Context, pid string) error {
-	return b.processes.DeleteProcess(pid)
-}
-
-func (b *localController) cancelRunningProcesses() {
-	b.processes.CancelRunningProcesses()
-}
-
-func (b *localController) Invoke(ctx context.Context, pid string, cfg *controllerapi.InvokeConfig, ioIn io.ReadCloser, ioOut io.WriteCloser, ioErr io.WriteCloser) error {
-	proc, ok := b.processes.Get(pid)
-	if !ok {
-		// Start a new process.
-		if b.buildConfig.resultCtx == nil {
-			return errors.New("no build result is registered")
-		}
-		var err error
-		proc, err = b.processes.StartProcess(pid, b.buildConfig.resultCtx, cfg)
-		if err != nil {
-			return err
-		}
-	}
-
-	// Attach containerIn to this process
-	ioCancelledCh := make(chan struct{})
-	proc.ForwardIO(&ioset.In{Stdin: ioIn, Stdout: ioOut, Stderr: ioErr}, func(error) { close(ioCancelledCh) })
-
-	select {
-	case <-ioCancelledCh:
-		return errors.Errorf("io cancelled")
-	case err := <-proc.Done():
-		return err
-	case <-ctx.Done():
-		return context.Cause(ctx)
-	}
-}
-
-func (b *localController) Close() error {
-	b.cancelRunningProcesses()
-	if b.buildConfig.resultCtx != nil {
-		b.buildConfig.resultCtx.Done()
-	}
-	// TODO: cancel ongoing builds?
-	return nil
-}
-
-func (b *localController) Inspect(ctx context.Context) *cbuild.Options {
-	return b.buildConfig.buildOptions
-}
diff --git a/controller/pb/invoke.go b/controller/pb/invoke.go
index 76839cc9..505d2c56 100644
--- a/controller/pb/invoke.go
+++ b/controller/pb/invoke.go
@@ -37,4 +37,20 @@ type InvokeConfig struct {
 	Tty        bool
 	Rollback   bool
 	Initial    bool
+	SuspendOn  SuspendOn
+}
+
+func (cfg *InvokeConfig) NeedsDebug(err error) bool {
+	return cfg.SuspendOn.DebugEnabled(err)
+}
+
+type SuspendOn int
+
+const (
+	SuspendError SuspendOn = iota
+	SuspendAlways
+)
+
+func (s SuspendOn) DebugEnabled(err error) bool {
+	return err != nil || s == SuspendAlways
 }
diff --git a/docs/reference/buildx_bake.md b/docs/reference/buildx_bake.md
index 733a3eae..189866b2 100644
--- a/docs/reference/buildx_bake.md
+++ b/docs/reference/buildx_bake.md
@@ -198,12 +198,15 @@ To list variables:
 
 ```console
 $ docker buildx bake --list=variables
-VARIABLE      VALUE                DESCRIPTION
-REGISTRY      docker.io/username   Registry and namespace
-IMAGE_NAME    my-app               Image name
-GO_VERSION    <null>
+VARIABLE      TYPE      VALUE                DESCRIPTION
+REGISTRY      string    docker.io/username   Registry and namespace
+IMAGE_NAME    string    my-app               Image name
+GO_VERSION              <null>
+DEBUG         bool      false                Add debug symbols
 ```
 
+Variable types will be shown when set using the `type` property in the Bake file.
+
 By default, the output of `docker buildx bake --list` is presented in a table
 format. Alternatively, you can use a long-form CSV syntax and specify a
 `format` attribute to output the list in JSON.
diff --git a/monitor/commands/reload.go b/monitor/commands/reload.go
index 2559d002..00a59f84 100644
--- a/monitor/commands/reload.go
+++ b/monitor/commands/reload.go
@@ -2,30 +2,16 @@ package commands
 
 import (
 	"context"
-	"fmt"
-	"io"
 
-	cbuild "github.com/docker/buildx/controller/build"
-	controllererrors "github.com/docker/buildx/controller/errdefs"
-	controllerapi "github.com/docker/buildx/controller/pb"
 	"github.com/docker/buildx/monitor/types"
-	"github.com/docker/buildx/util/progress"
-	"github.com/moby/buildkit/solver/errdefs"
-	"github.com/pkg/errors"
 )
 
 type ReloadCmd struct {
 	m types.Monitor
-
-	stdout   io.WriteCloser
-	progress *progress.Printer
-
-	options      *cbuild.Options
-	invokeConfig *controllerapi.InvokeConfig
 }
 
-func NewReloadCmd(m types.Monitor, stdout io.WriteCloser, progress *progress.Printer, options *cbuild.Options, invokeConfig *controllerapi.InvokeConfig) types.Command {
-	return &ReloadCmd{m, stdout, progress, options, invokeConfig}
+func NewReloadCmd(m types.Monitor) types.Command {
+	return &ReloadCmd{m: m}
 }
 
 func (cm *ReloadCmd) Info() types.CommandInfo {
@@ -40,31 +26,6 @@ Usage:
 }
 
 func (cm *ReloadCmd) Exec(ctx context.Context, args []string) error {
-	bo := cm.m.Inspect(ctx)
-
-	var resultUpdated bool
-	cm.progress.Unpause()
-	_, _, err := cm.m.Build(ctx, bo, nil, cm.progress) // TODO: support stdin, hold build ref
-	cm.progress.Pause()
-	if err != nil {
-		var be *controllererrors.BuildError
-		if errors.As(err, &be) {
-			resultUpdated = true
-		} else {
-			fmt.Printf("failed to reload: %v\n", err)
-		}
-		// report error
-		for _, s := range errdefs.Sources(err) {
-			s.Print(cm.stdout)
-		}
-		fmt.Fprintf(cm.stdout, "ERROR: %v\n", err)
-	} else {
-		resultUpdated = true
-	}
-	if resultUpdated {
-		// rollback the running container with the new result
-		id := cm.m.Rollback(ctx, cm.invokeConfig)
-		fmt.Fprintf(cm.stdout, "Interactive container was restarted with process %q. Press Ctrl-a-c to switch to the new container\n", id)
-	}
+	cm.m.Reload()
 	return nil
 }
diff --git a/monitor/monitor.go b/monitor/monitor.go
index 750f4439..35ef37bf 100644
--- a/monitor/monitor.go
+++ b/monitor/monitor.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"os"
 	"sort"
 	"sync"
 	"sync/atomic"
@@ -11,39 +12,115 @@ import (
 
 	"github.com/containerd/console"
 	"github.com/docker/buildx/build"
-	cbuild "github.com/docker/buildx/controller/build"
-	"github.com/docker/buildx/controller/control"
 	controllerapi "github.com/docker/buildx/controller/pb"
+	"github.com/docker/buildx/controller/processes"
 	"github.com/docker/buildx/monitor/commands"
 	"github.com/docker/buildx/monitor/types"
 	"github.com/docker/buildx/util/ioset"
 	"github.com/docker/buildx/util/progress"
 	"github.com/google/shlex"
-	"github.com/moby/buildkit/client"
 	"github.com/moby/buildkit/identity"
+	"github.com/moby/buildkit/solver/errdefs"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
 	"golang.org/x/term"
 )
 
-type MonitorBuildResult struct {
-	Resp *client.SolveResponse
-	Err  error
+var ErrReload = errors.New("monitor: reload")
+
+type Monitor struct {
+	invokeConfig *controllerapi.InvokeConfig
+	printer      *progress.Printer
+
+	stdin  *ioset.SingleForwarder
+	stdout io.WriteCloser
+	stderr io.WriteCloser
+
+	res *build.ResultHandle
+	idx int
+	mu  sync.Mutex
 }
 
-// RunMonitor provides an interactive session for running and managing containers via specified IO.
-func RunMonitor(ctx context.Context, curRef string, options *cbuild.Options, invokeConfig *controllerapi.InvokeConfig, c control.BuildxController, stdin io.ReadCloser, stdout io.WriteCloser, stderr console.File, progress *progress.Printer) (*MonitorBuildResult, error) {
-	defer func() {
-		if err := c.Close(); err != nil {
-			logrus.Warnf("close error: %v", err)
-		}
-	}()
+func New(cfg *controllerapi.InvokeConfig, stdin io.ReadCloser, stdout, stderr io.WriteCloser, printer *progress.Printer) *Monitor {
+	m := &Monitor{
+		invokeConfig: cfg,
+		printer:      printer,
+		stdin:        ioset.NewSingleForwarder(),
+		stdout:       stdout,
+		stderr:       stderr,
+	}
+	m.stdin.SetReader(stdin)
+	return m
+}
+
+func (m *Monitor) Handler() build.Handler {
+	return build.Handler{
+		OnResult: func(driverIndex int, gotRes *build.ResultHandle) {
+			m.mu.Lock()
+			defer m.mu.Unlock()
+
+			if m.res == nil || driverIndex < m.idx {
+				m.idx, m.res = driverIndex, gotRes
+			}
+		},
+	}
+}
+
+func (m *Monitor) Run(ctx context.Context, buildErr error) error {
+	defer m.reset()
+
+	if !m.invokeConfig.NeedsDebug(buildErr) {
+		return nil
+	}
+
+	// Print errors before launching monitor
+	if err := printError(buildErr, m.printer); err != nil {
+		logrus.Warnf("failed to print error information: %v", err)
+	}
+
+	pr, pw := io.Pipe()
+	m.stdin.SetWriter(pw, func() io.WriteCloser {
+		pw.Close() // propagate EOF
+		return nil
+	})
+
+	con := console.Current()
+	if err := con.SetRaw(); err != nil {
+		return errors.Errorf("failed to configure terminal: %v", err)
+	}
+	defer con.Reset()
+
+	monitorErr := RunMonitor(ctx, m.invokeConfig, m.res, pr, m.stdout, m.stderr, m.printer)
+	if err := pw.Close(); err != nil {
+		logrus.Debug("failed to close monitor stdin pipe reader")
+	}
+	return monitorErr
+}
+
+func (m *Monitor) reset() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	m.idx = 0
+	if m.res != nil {
+		m.res.Done()
+		m.res = nil
+	}
+}
+
+func (m *Monitor) Close() error {
+	return m.stdin.Close()
+}
 
+// RunMonitor provides an interactive session for running and managing containers via specified IO.
+func RunMonitor(ctx context.Context, invokeConfig *controllerapi.InvokeConfig, rCtx *build.ResultHandle, stdin io.ReadCloser, stdout, stderr io.WriteCloser, progress *progress.Printer) error {
 	if err := progress.Pause(); err != nil {
-		return nil, err
+		return err
 	}
 	defer progress.Unpause()
 
+	defer stdin.Close()
+
 	monitorIn, monitorOut := ioset.Pipe()
 	defer func() {
 		monitorIn.Close()
@@ -70,8 +147,9 @@ func RunMonitor(ctx context.Context, curRef string, options *cbuild.Options, inv
 	invokeForwarder := ioset.NewForwarder()
 	invokeForwarder.SetIn(&containerIn)
 	m := &monitor{
-		BuildxController: c,
-		invokeIO:         invokeForwarder,
+		rCtx:      rCtx,
+		processes: processes.NewManager(),
+		invokeIO:  invokeForwarder,
 		muxIO: ioset.NewMuxIO(ioset.In{
 			Stdin:  io.NopCloser(stdin),
 			Stdout: nopCloser{stdout},
@@ -84,7 +162,13 @@ func RunMonitor(ctx context.Context, curRef string, options *cbuild.Options, inv
 			return "Switched IO\n"
 		}),
 	}
-	m.ref.Store(curRef)
+	m.ctx, m.cancel = context.WithCancelCause(context.Background())
+
+	defer func() {
+		if err := m.Close(); err != nil {
+			logrus.Warnf("close error: %v", err)
+		}
+	}()
 
 	// Start container automatically
 	fmt.Fprintf(stdout, "Launching interactive container. Press Ctrl-a-c to switch to monitor console\n")
@@ -94,7 +178,7 @@ func RunMonitor(ctx context.Context, curRef string, options *cbuild.Options, inv
 	fmt.Fprintf(stdout, "Interactive container was restarted with process %q. Press Ctrl-a-c to switch to the new container\n", id)
 
 	availableCommands := []types.Command{
-		commands.NewReloadCmd(m, stdout, progress, options, invokeConfig),
+		commands.NewReloadCmd(m),
 		commands.NewRollbackCmd(m, invokeConfig, stdout),
 		commands.NewAttachCmd(m, stdout),
 		commands.NewExecCmd(m, invokeConfig, stdout),
@@ -126,6 +210,11 @@ func RunMonitor(ctx context.Context, curRef string, options *cbuild.Options, inv
 			}()
 			t := term.NewTerminal(readWriter{in.Stdin, in.Stdout}, "(buildx) ")
 			for {
+				if err := m.ctx.Err(); err != nil {
+					errCh <- context.Cause(m.ctx)
+					return
+				}
+
 				l, err := t.ReadLine()
 				if err != nil {
 					if err != io.EOF {
@@ -174,10 +263,10 @@ func RunMonitor(ctx context.Context, curRef string, options *cbuild.Options, inv
 		select {
 		case <-doneCh:
 			m.close()
-			return m.lastBuildResult, nil
+			return nil
 		case err := <-errCh:
 			m.close()
-			return m.lastBuildResult, err
+			return err
 		case <-monitorDisableCh:
 		}
 		monitorForwarder.SetOut(nil)
@@ -231,21 +320,45 @@ type readWriter struct {
 }
 
 type monitor struct {
-	control.BuildxController
-	ref atomic.Value
+	ctx    context.Context
+	cancel context.CancelCauseFunc
+
+	rCtx *build.ResultHandle
 
 	muxIO        *ioset.MuxIO
 	invokeIO     *ioset.Forwarder
 	invokeCancel func()
 	attachedPid  atomic.Value
 
-	lastBuildResult *MonitorBuildResult
+	processes *processes.Manager
 }
 
-func (m *monitor) Build(ctx context.Context, options *cbuild.Options, in io.ReadCloser, progress progress.Writer) (resp *client.SolveResponse, input *build.Inputs, err error) {
-	resp, _, err = m.BuildxController.Build(ctx, options, in, progress)
-	m.lastBuildResult = &MonitorBuildResult{Resp: resp, Err: err} // Record build result
-	return
+func (m *monitor) Invoke(ctx context.Context, pid string, cfg *controllerapi.InvokeConfig, ioIn io.ReadCloser, ioOut io.WriteCloser, ioErr io.WriteCloser) error {
+	proc, ok := m.processes.Get(pid)
+	if !ok {
+		// Start a new process.
+		if m.rCtx == nil {
+			return errors.New("no build result is registered")
+		}
+		var err error
+		proc, err = m.processes.StartProcess(pid, m.rCtx, cfg)
+		if err != nil {
+			return err
+		}
+	}
+
+	// Attach containerIn to this process
+	ioCancelledCh := make(chan struct{})
+	proc.ForwardIO(&ioset.In{Stdin: ioIn, Stdout: ioOut, Stderr: ioErr}, func(error) { close(ioCancelledCh) })
+
+	select {
+	case <-ioCancelledCh:
+		return errors.Errorf("io cancelled")
+	case err := <-proc.Done():
+		return err
+	case <-ctx.Done():
+		return context.Cause(ctx)
+	}
 }
 
 func (m *monitor) Rollback(ctx context.Context, cfg *controllerapi.InvokeConfig) string {
@@ -269,6 +382,10 @@ func (m *monitor) Detach() {
 	}
 }
 
+func (m *monitor) Reload() {
+	m.cancel(ErrReload)
+}
+
 func (m *monitor) AttachedPID() string {
 	return m.attachedPid.Load().(string)
 }
@@ -332,8 +449,40 @@ func (m *monitor) invoke(ctx context.Context, pid string, cfg *controllerapi.Inv
 	return err
 }
 
+func (m *monitor) Close() error {
+	m.cancelRunningProcesses()
+	return nil
+}
+
+func (m *monitor) ListProcesses(ctx context.Context) (infos []*processes.ProcessInfo, retErr error) {
+	return m.processes.ListProcesses(), nil
+}
+
+func (m *monitor) DisconnectProcess(ctx context.Context, pid string) error {
+	return m.processes.DeleteProcess(pid)
+}
+
+func (m *monitor) cancelRunningProcesses() {
+	m.processes.CancelRunningProcesses()
+}
+
 type nopCloser struct {
 	io.Writer
 }
 
 func (c nopCloser) Close() error { return nil }
+
+func printError(err error, printer *progress.Printer) error {
+	if err == nil {
+		return nil
+	}
+	if err := printer.Pause(); err != nil {
+		return err
+	}
+	defer printer.Unpause()
+	for _, s := range errdefs.Sources(err) {
+		s.Print(os.Stderr)
+	}
+	fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
+	return nil
+}
diff --git a/monitor/types/types.go b/monitor/types/types.go
index 201f273a..fb17b52f 100644
--- a/monitor/types/types.go
+++ b/monitor/types/types.go
@@ -2,14 +2,23 @@ package types
 
 import (
 	"context"
+	"io"
 
-	"github.com/docker/buildx/controller/control"
 	controllerapi "github.com/docker/buildx/controller/pb"
+	"github.com/docker/buildx/controller/processes"
 )
 
 // Monitor provides APIs for attaching and controlling the buildx server.
 type Monitor interface {
-	control.BuildxController
+	// Invoke starts an IO session into the specified process.
+	// If pid doesn't match to any running processes, it starts a new process with the specified config.
+	// If there is no container running or InvokeConfig.Rollback is specified, the process will start in a newly created container.
+	// NOTE: If needed, in the future, we can split this API into three APIs (NewContainer, NewProcess and Attach).
+	Invoke(ctx context.Context, pid string, options *controllerapi.InvokeConfig, ioIn io.ReadCloser, ioOut io.WriteCloser, ioErr io.WriteCloser) error
+
+	ListProcesses(ctx context.Context) (infos []*processes.ProcessInfo, retErr error)
+
+	DisconnectProcess(ctx context.Context, pid string) error
 
 	// Rollback re-runs the interactive container with initial rootfs contents.
 	Rollback(ctx context.Context, cfg *controllerapi.InvokeConfig) string
@@ -25,6 +34,11 @@ type Monitor interface {
 
 	// Detach detaches IO from the container.
 	Detach()
+
+	// Reload will signal the monitor to be reloaded.
+	Reload()
+
+	io.Closer
 }
 
 // CommandInfo is information about a command.
diff --git a/util/ioset/mux.go b/util/ioset/mux.go
index 1d3ec616..143ce853 100644
--- a/util/ioset/mux.go
+++ b/util/ioset/mux.go
@@ -229,20 +229,24 @@ func copyToFunc(r io.Reader, wFunc func() (io.Writer, error)) error {
 	buf := make([]byte, 4096)
 	for {
 		n, readErr := r.Read(buf)
-		if readErr != nil && readErr != io.EOF {
-			return readErr
-		}
-		w, err := wFunc()
-		if err != nil {
-			return err
-		}
-		if w != nil {
-			if _, err := w.Write(buf[:n]); err != nil {
-				logrus.WithError(err).Debugf("failed to copy")
+
+		if n > 0 {
+			w, err := wFunc()
+			if err != nil {
+				return err
+			}
+			if w != nil {
+				if _, err := w.Write(buf[:n]); err != nil {
+					logrus.WithError(err).Debugf("failed to copy")
+				}
 			}
 		}
-		if readErr == io.EOF {
-			return nil
+
+		if readErr != nil {
+			if isReaderClosed(readErr) {
+				return nil
+			}
+			return readErr
 		}
 	}
 }
@@ -255,3 +259,7 @@ type readerWithClose struct {
 func (r *readerWithClose) Close() error {
 	return r.closeFunc()
 }
+
+func isReaderClosed(err error) bool {
+	return errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe)
+}



Test patch:
diff --git a/tests/bake.go b/tests/bake.go
index 7065726a..ca9902a4 100644
--- a/tests/bake.go
+++ b/tests/bake.go
@@ -74,6 +74,7 @@ var bakeTests = []func(t *testing.T, sb integration.Sandbox){
 	testBakeLoadPush,
 	testListTargets,
 	testListVariables,
+	testListTypedVariables,
 	testBakeCallCheck,
 	testBakeCallCheckFlag,
 	testBakeCallMetadata,
@@ -1737,7 +1738,93 @@ target "default" {
 	)
 	require.NoError(t, err, out)
 
-	require.Equal(t, "VARIABLE\tVALUE\tDESCRIPTION\nabc\t\t<null>\t\ndef\t\t\t\nfoo\t\tbar\tThis is foo", strings.TrimSpace(out))
+	require.Equal(t, "VARIABLE\tTYPE\tVALUE\tDESCRIPTION\nabc\t\t\t<null>\t\ndef\t\t\t\t\nfoo\t\t\tbar\tThis is foo", strings.TrimSpace(out))
+}
+
+func testListTypedVariables(t *testing.T, sb integration.Sandbox) {
+	bakefile := []byte(`
+variable "abc" {
+    type = string
+	default = "bar"
+	description = "This is abc"
+}
+variable "def" {
+    type = string
+    description = "simple type, no default"
+}
+variable "ghi" {
+    type = number
+    default = 99
+    description = "simple type w/ default"
+}
+variable "jkl" {
+    type = list(string)
+    default = ["hello"]
+    description = "collection with quoted strings"
+}
+variable "mno" {
+    type = list(number)
+    description = "collection, no default"
+}
+variable "pqr" {
+    type = tuple([number, string, bool])
+    default = [99, "99", true]
+}
+variable "stu" {
+    type = map(string)
+    default = {"foo": "bar"}
+}
+variable "vwx" {
+    type = set(bool)
+    default = null
+    description = "collection, null default"
+}
+
+// untyped, but previously didn't have its value output
+variable "wxy" {
+    default = ["foo"]
+    description = "inferred tuple"
+}
+// untyped, but previously didn't have its value output
+variable "xyz" {
+    default = {"foo": "bar"}
+    description = "inferred object"
+}
+// untyped, but previously didn't have its value output
+variable "yza" {
+    default = true
+    description = "inferred bool"
+}
+target "default" {
+}
+`)
+	dir := tmpdir(
+		t,
+		fstest.CreateFile("docker-bake.hcl", bakefile, 0600),
+	)
+
+	out, err := bakeCmd(
+		sb,
+		withDir(dir),
+		withArgs("--list=variables"),
+	)
+	require.NoError(t, err, out)
+	require.Equal(t,
+		"VARIABLE\tTYPE\t\tVALUE\t\tDESCRIPTION\n"+
+			"abc\t\tstring\t\tbar\t\tThis is abc\n"+
+			"def\t\tstring\t\t<null>\t\tsimple type, no default\n"+
+			"ghi\t\tnumber\t\t99\t\tsimple type w/ default\n"+
+			"jkl\t\tlist of string\t[\"hello\"]\tcollection with quoted strings\n"+
+			"mno\t\tlist of number\t<null>\t\tcollection, no default\n"+
+			// the implementation for tuple's 'friendly name' is very basic
+			// and marked as TODO, so this may change/break at some point
+			"pqr\t\ttuple\t\t[99,\"99\",true]\t\n"+
+			"stu\t\tmap of string\t{\"foo\":\"bar\"}\t\n"+
+			"vwx\t\tset of bool\t<null>\t\tcollection, null default\n"+
+			"wxy\t\t\t\t[\"foo\"]\t\tinferred tuple\n"+
+			"xyz\t\t\t\t{\"foo\":\"bar\"}\tinferred object\n"+
+			"yza\t\t\t\ttrue\t\tinferred bool",
+		strings.TrimSpace(out))
 }
 
 func testBakeCallCheck(t *testing.T, sb integration.Sandbox) {