Files
buildx/dap/thread.go
T
2026-01-09 18:54:15 -08:00

681 lines
15 KiB
Go

package dap
import (
"context"
"path"
"path/filepath"
"slices"
"sync"
"github.com/docker/buildx/build"
"github.com/docker/buildx/dap/common"
"github.com/google/go-dap"
"github.com/moby/buildkit/client/llb"
gateway "github.com/moby/buildkit/frontend/gateway/client"
"github.com/moby/buildkit/solver/errdefs"
"github.com/moby/buildkit/solver/pb"
"github.com/opencontainers/go-digest"
"github.com/pkg/errors"
"golang.org/x/sync/errgroup"
)
type thread struct {
// Persistent data.
id int
name string
// Persistent state from the adapter.
sharedState
variables *variableReferences
// Inputs to the evaluate call.
c gateway.Client
ref gateway.Reference
meta map[string][]byte
sourcePath string
// LLB state for the evaluate call.
def *llb.Definition
ops map[digest.Digest]*pb.Op
head digest.Digest
bps map[digest.Digest]int
frames map[int32]*frame
// Runtime state for the evaluate call.
entrypoint *step
// Controls pause.
paused chan stepType
mu sync.Mutex
// Attributes set when a thread is paused.
cancel context.CancelCauseFunc // invoked when the thread is resumed
rCtx *build.ResultHandle
stackTrace []int32
}
type stepType int
const (
stepContinue stepType = iota
stepNext
stepIn
stepOut
)
func (t *thread) Evaluate(ctx Context, c gateway.Client, headRef gateway.Reference, meta map[string][]byte, inputs build.Inputs, cfg common.Config) error {
if err := t.init(ctx, c, headRef, meta, inputs); err != nil {
return err
}
defer t.reset()
action := stepContinue
if cfg.StopOnEntry {
action = stepNext
}
var (
k string
refs map[string]gateway.Reference
next = t.entrypoint
err error
)
for next != nil {
event := t.needsDebug(next, action, err)
if event.Reason != "" {
select {
case action = <-t.pause(ctx, k, refs, err, next, event):
// do nothing here
case <-ctx.Done():
return context.Cause(ctx)
}
}
if err != nil {
return err
}
t.setBreakpoints(ctx)
k, next, refs, err = t.seekNext(ctx, next, action)
}
return nil
}
func (t *thread) init(ctx Context, c gateway.Client, ref gateway.Reference, meta map[string][]byte, inputs build.Inputs) error {
t.c = c
t.ref = ref
t.meta = meta
// Combine the dockerfile directory with the context path to find the
// real base path. The frontend will report the base path as the filename.
dir := path.Dir(inputs.DockerfilePath)
if !path.IsAbs(dir) {
dir = path.Join(inputs.ContextPath, dir)
}
t.sourcePath = dir
if err := t.getLLBState(ctx); err != nil {
return err
}
return t.createProgram()
}
type step struct {
// dgst holds the digest associated with this step. This is used for
// breakpoint resolution.
dgst digest.Digest
// in holds the next target when step in is used.
in *step
// out holds the next target when step out is used.
out *step
// next holds the next target when next is used.
next *step
// frame will hold the stack frame associated with this step.
frame *frame
// parent holds the index of the parent step.
parent int
}
func (t *thread) createProgram() error {
t.frames = make(map[int32]*frame)
// Create the entrypoint by using the last node.
// We will build on top of that.
t.entrypoint = t.createBranch(t.head, nil)
return nil
}
func (t *thread) createBranch(dgst digest.Digest, exitpoint *step) (entrypoint *step) {
// Construct the final two steps in this branch. The final steps
// both point to the same line. The difference between them is one
// step is before the execution of the digest and the other is
// after the execution of that digest.
returnpoint := &step{
in: exitpoint,
next: exitpoint,
out: exitpoint,
parent: -1,
}
entrypoint = &step{
dgst: dgst,
in: returnpoint,
next: returnpoint,
out: exitpoint,
frame: t.getStackFrame(dgst, nil),
parent: -1,
}
// Create a pseudo-frame and attach it to the return point.
// This is mostly used for getting the correct inputs utilized
// by this frame.
//
// We don't save this frame or assign it a unique ID as it should
// never be returned.
returnpoint.frame = &frame{
StackFrame: entrypoint.frame.StackFrame,
op: &pb.Op{
Inputs: []*pb.Input{
{Digest: string(dgst), Index: 0},
},
},
}
for {
// Construct the input step for this digest based on the inputs.
op := t.ops[entrypoint.dgst]
if len(op.Inputs) == 0 {
return entrypoint
}
entrypoint.parent = t.determineParent(op)
for i := len(op.Inputs) - 1; i >= 0; i-- {
if i == entrypoint.parent {
// Skip the direct parent.
continue
}
// When we find inputs that aren't the direct parent,
// we want to add them as a step before the current step.
// We have to do a few things when inserting this.
//
// 1. We move the digest from the old entrypoint to this node.
// This is so the breakpoint happens before these inputs
// are evaluated.
// 2. We keep the next/out pointers the same but redirect in
// to point to the new branch.
// 3. The direct parent is excluded from this logic. We handle
// that later.
inp := op.Inputs[i]
head := *entrypoint
entrypoint.dgst = ""
// Create the routine associated with this input.
// Associate it with the entrypoint in step.
head.in = t.createBranch(digest.Digest(inp.Digest), entrypoint)
entrypoint = &head
}
// If we have no direct parent, return the current entrypoint
// as the beginning.
if entrypoint.parent < 0 {
return entrypoint
}
// Create a new step that refers to the direct parent.
head := &step{
dgst: digest.Digest(op.Inputs[entrypoint.parent].Digest),
in: entrypoint,
next: entrypoint,
out: entrypoint.out,
parent: -1,
}
head.frame = t.getStackFrame(head.dgst, entrypoint)
entrypoint = head
}
}
func (t *thread) getStackFrame(dgst digest.Digest, next *step) *frame {
f := &frame{
op: t.ops[dgst],
}
f.Id = int(t.idPool.Get())
if meta, ok := t.def.Metadata[dgst]; ok {
f.setNameFromMeta(meta)
}
if loc, ok := t.def.Source.Locations[string(dgst)]; ok {
f.fillLocation(t.def, loc, t.sourcePath, next)
}
t.frames[int32(f.Id)] = f
return f
}
func (t *thread) determineParent(op *pb.Op) int {
// Another section should have already checked this but
// double check here just in case we forget somewhere else.
// The rest of this method assumes there's at least one parent
// at index zero.
n := len(op.Inputs)
if n == 0 {
return -1
}
switch op := op.Op.(type) {
case *pb.Op_Exec:
for _, m := range op.Exec.Mounts {
if m.Dest == "/" {
return int(m.Input)
}
}
return -1
case *pb.Op_File:
// Use the first input where the index is from one of the inputs.
for _, action := range op.File.Actions {
if input := int(action.Input); input >= 0 && input < n {
return input
}
}
// Default to having no parent.
return -1
default:
// Default to index zero.
return 0
}
}
func (t *thread) reset() {
t.c = nil
t.ref = nil
t.meta = nil
t.sourcePath = ""
t.ops = nil
}
func (t *thread) needsDebug(cur *step, step stepType, err error) (e dap.StoppedEventBody) {
if err != nil {
e.Reason = "exception"
e.Description = "Encountered an error during result evaluation"
} else if cur != nil {
if step != stepContinue {
e.Reason = "step"
} else if id, ok := t.bps[cur.dgst]; ok {
e.Reason = "breakpoint"
e.Description = "Paused on breakpoint"
e.HitBreakpointIds = []int{id}
}
}
return
}
func (t *thread) pause(c Context, k string, refs map[string]gateway.Reference, err error, pos *step, event dap.StoppedEventBody) <-chan stepType {
t.mu.Lock()
defer t.mu.Unlock()
if t.paused != nil {
return t.paused
}
t.paused = make(chan stepType, 1)
ctx, cancel := context.WithCancelCause(c)
t.collectStackTrace(ctx, pos, refs)
t.cancel = cancel
// Used for exec. Only works if there was an error or if the step returns
// a root mount.
if ref, ok := refs[k]; ok || err != nil {
t.prepareResultHandle(c, ref, err)
}
event.ThreadId = t.id
c.C() <- &dap.StoppedEvent{
Event: dap.Event{Event: "stopped"},
Body: event,
}
return t.paused
}
func (t *thread) prepareResultHandle(c Context, ref gateway.Reference, err error) {
// Create a context for cancellations and make the cancel function
// block on the wait group.
var wg sync.WaitGroup
ctx, cancel := context.WithCancelCause(c)
t.cancel = func(cause error) {
defer wg.Wait()
cancel(cause)
}
t.rCtx = build.NewResultHandle(ctx, t.c, ref, t.meta, err)
// Start the attach. Use the context we created and perform it in
// a goroutine. We aren't necessarily assuming this will actually work.
wg.Go(func() {
t.sh.Attach(ctx, t)
})
}
func (t *thread) Continue() {
t.resume(stepContinue)
}
func (t *thread) Next() {
t.resume(stepNext)
}
func (t *thread) StepIn() {
t.resume(stepIn)
}
func (t *thread) StepOut() {
t.resume(stepOut)
}
func (t *thread) resume(step stepType) {
t.mu.Lock()
defer t.mu.Unlock()
if t.paused == nil {
return
}
t.releaseState()
t.paused <- step
close(t.paused)
t.paused = nil
}
func (t *thread) StackTrace() []dap.StackFrame {
t.mu.Lock()
defer t.mu.Unlock()
if t.paused == nil {
// Cannot compute stack trace when not paused.
// This should never happen, but protect ourself in
// case it does.
return []dap.StackFrame{}
}
frames := make([]dap.StackFrame, len(t.stackTrace))
for i, id := range t.stackTrace {
frames[i] = t.frames[id].StackFrame
}
return frames
}
func (t *thread) Scopes(frameID int) []dap.Scope {
t.mu.Lock()
defer t.mu.Unlock()
frame := t.frames[int32(frameID)]
return frame.Scopes()
}
func (t *thread) Variables(id int) []dap.Variable {
return t.variables.Get(id)
}
func (t *thread) getLLBState(ctx Context) error {
st, err := t.ref.ToState()
if err != nil {
return err
}
t.def, err = st.Marshal(ctx)
if err != nil {
return err
}
for _, src := range t.def.Source.Infos {
fname := filepath.Join(t.sourcePath, src.Filename)
t.sourceMap.Put(ctx, fname, src.Data)
}
t.ops = make(map[digest.Digest]*pb.Op, len(t.def.Def))
for _, dt := range t.def.Def {
dgst := digest.FromBytes(dt)
var op pb.Op
if err := op.Unmarshal(dt); err != nil {
return err
}
t.ops[dgst] = &op
}
t.head, err = t.def.Head()
return err
}
func (t *thread) setBreakpoints(ctx Context) {
t.bps = t.breakpointMap.Intersect(ctx, t.def.Source, t.sourcePath)
}
func (t *thread) seekNext(ctx Context, from *step, action stepType) (string, *step, map[string]gateway.Reference, error) {
// If we're at the end, return no digest to signal that
// we should conclude debugging.
var target *step
switch action {
case stepNext:
target = t.continueDigest(from, from.next)
case stepIn:
target = from.in
case stepOut:
target = t.continueDigest(from, from.out)
case stepContinue:
target = t.continueDigest(from, nil)
}
return t.seek(ctx, target)
}
func (t *thread) seek(ctx Context, target *step) (k string, result *step, mounts map[string]gateway.Reference, err error) {
k = "/"
var refs map[string]gateway.Reference
if target != nil {
k, refs, err = t.solveInputs(ctx, target)
if err != nil {
return "", nil, nil, err
}
result = target
} else {
refs = map[string]gateway.Reference{"/": t.ref}
}
if len(refs) > 0 {
if err := t.evaluateRefs(ctx, refs); err != nil {
return t.rewind(ctx, err)
}
}
return k, result, refs, nil
}
func (t *thread) continueDigest(from, until *step) *step {
if len(t.bps) == 0 && until == nil {
return nil
}
isBreakpoint := func(dgst digest.Digest) bool {
if dgst == "" {
return false
}
_, ok := t.bps[dgst]
return ok
}
next := func(s *step) *step {
cur := s.in
for cur != nil && cur != until {
if isBreakpoint(cur.dgst) {
return cur
}
cur = cur.in
}
return until
}
return next(from)
}
func (t *thread) solveInputs(ctx context.Context, target *step) (string, map[string]gateway.Reference, error) {
if target == nil || target.frame.op == nil {
return "", nil, nil
}
op := target.frame.op
var root string
refs := make(map[string]gateway.Reference)
for i, input := range op.Inputs {
k := t.determineInputName(op, i, input)
if _, ok := refs[k]; ok || k == "" {
continue
}
if i == target.parent {
root = k
}
ref, err := t.solve(ctx, input)
if err != nil {
return "", nil, err
}
refs[k] = ref
}
return root, refs, nil
}
func (t *thread) determineInputName(op *pb.Op, index int, input *pb.Input) string {
switch op := op.Op.(type) {
case *pb.Op_Exec:
for _, m := range op.Exec.Mounts {
if m.Input >= 0 && m.Input == int64(index) {
return m.Dest
}
}
}
return input.Digest
}
func (t *thread) evaluateRefs(ctx context.Context, refs map[string]gateway.Reference) error {
eg, _ := errgroup.WithContext(ctx)
for _, ref := range refs {
eg.Go(func() error {
return ref.Evaluate(ctx)
})
}
return eg.Wait()
}
func (t *thread) solve(ctx context.Context, input *pb.Input) (gateway.Reference, error) {
if input.Digest == string(t.head) {
return t.ref, nil
}
head := &pb.Op{
Inputs: []*pb.Input{input},
}
dt, err := head.Marshal()
if err != nil {
return nil, err
}
def := t.def.ToPB()
def.Def[len(def.Def)-1] = dt
res, err := t.c.Solve(ctx, gateway.SolveRequest{
Definition: def,
})
if err != nil {
return nil, err
}
return res.SingleRef()
}
func (t *thread) releaseState() {
if t.rCtx != nil {
t.rCtx.Done()
t.rCtx = nil
}
for _, f := range t.frames {
f.ResetVars()
}
if t.cancel != nil {
t.cancel(context.Canceled)
t.cancel = nil
}
t.stackTrace = t.stackTrace[:0]
t.variables.Reset()
}
func (t *thread) collectStackTrace(ctx context.Context, pos *step, mounts map[string]gateway.Reference) {
for pos != nil {
frame := pos.frame
frame.ExportVars(ctx, mounts, t.variables)
t.stackTrace = append(t.stackTrace, int32(frame.Id))
pos, mounts = pos.out, nil
}
}
func (t *thread) hasFrame(id int) bool {
t.mu.Lock()
defer t.mu.Unlock()
if t.paused == nil {
return false
}
_, ok := t.frames[int32(id)]
return ok
}
func (t *thread) rewind(ctx Context, inErr error) (k string, result *step, mounts map[string]gateway.Reference, retErr error) {
var solveErr *errdefs.SolveError
if !errors.As(inErr, &solveErr) {
// If this is not a solve error, do not return the
// reference and target step.
return "", nil, nil, inErr
}
// Find the error digests we might have failed on.
var digests []digest.Digest
if dt, err := solveErr.Op.Marshal(); err == nil {
digests = append(digests, digest.FromBytes(dt))
}
// Include a version of the digest without the platform
// if this is a file op.
if _, ok := solveErr.Op.Op.(*pb.Op_File); ok && solveErr.Op.Platform != nil {
op := solveErr.Op.CloneVT()
op.Platform = nil
if dt, err := op.Marshal(); err == nil {
digests = append(digests, digest.FromBytes(dt))
}
}
if len(digests) == 0 {
return "", nil, nil, inErr
}
// Iterate from the first step to find the one we failed on.
result = t.entrypoint
for result != nil && !slices.Contains(digests, result.dgst) {
result = result.in
}
// Seek to this step. This should succeed because otherwise
// we wouldn't have been able to even fail on it to begin with.
k, result, mounts, retErr = t.seek(ctx, result)
if retErr != nil {
return k, result, mounts, retErr
}
return k, result, mounts, inErr
}