Files
buildx/dap/thread.go
T
Jonathan A. Sternberg 4f3de79c13 dap: properly map source paths to client side paths
Properly map the source paths from the metadata in the solve to the
client side paths. The source path returns is relative to the context
that gets uploaded which is usually a subdirectory. The original code
noticed this when mapping the paths but made the invalid assumption that
the dockerfile would always be in the context path so it combined the
dockerfile name with the context path.

It is possible for the dockerfile to be in a subdirectory of the
context. In which case, we computed the paths incorrectly.

This modifies DAP to instead use the `DockerfileMappingDst` and
`DockerfileMappingSrc` which are special included variables to the
inputs that get filled in during the build for the purpose of mapping
the source path to the client side path.

Tests have also been added for this functionality to ensure it doesn't
break again. This should work with both absolute and relative paths
although absolute paths should probably be preferred for usage just
because they're less likely to result in weird things happening.

The sources are also normalized to always convert the source filenames
to absolute paths and DAP itself will accept relative paths but will
only ever communicate in absolute paths. When you set a breakpoint, it
will convert it to an absolute path and reference it in that way rather
than a relative path.

Signed-off-by: Jonathan A. Sternberg <jonathan.sternberg@docker.com>
2026-03-10 09:17:42 -05:00

781 lines
18 KiB
Go

package dap
import (
"context"
"path/filepath"
"slices"
"strings"
"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"
gwpb "github.com/moby/buildkit/frontend/gateway/pb"
"github.com/moby/buildkit/solver/errdefs"
"github.com/moby/buildkit/solver/pb"
"github.com/opencontainers/go-digest"
"github.com/pkg/errors"
"github.com/tonistiigi/fsutil/types"
"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
sourceInfoMap func(*pb.Source) *pb.Source
// 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
t.sourceInfoMap = func(s *pb.Source) *pb.Source {
s = s.CloneVT()
for _, sinfo := range s.Infos {
// Map the filename from the source info from the frontend location to the
// client location.
fname := strings.Replace(sinfo.Filename, inputs.DockerfileMappingDst, inputs.DockerfileMappingSrc, 1)
// Convert to an absolute path.
if abspath, err := filepath.Abs(fname); err == nil {
fname = abspath
}
sinfo.Filename = fname
}
return s
}
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, 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.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)
t.prepareResultHandle(c, k, refs, err)
ctx, cancel := context.WithCancelCause(c)
t.collectStackTrace(ctx, pos, refs)
t.cancel = cancel
event.ThreadId = t.id
c.C() <- &dap.StoppedEvent{
Event: dap.Event{Event: "stopped"},
Body: event,
}
return t.paused
}
func (t *thread) prepareResultHandle(c Context, k string, refs map[string]gateway.Reference, err error) {
var ref gateway.Reference
if err == nil {
var ok bool
if ref, ok = refs[k]; !ok {
return
}
}
// 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)
if err != nil {
gwcaps := t.c.BuildOpts().Caps
var solveErr *errdefs.SolveError
// If we had a solve error and the exec filesystem capability, we can
// get the filesystem mounts used in the actual build rather than only the input
// mounts.
if gwcaps.Supports(gwpb.CapGatewayExecFilesystem) == nil && errors.As(err, &solveErr) {
if exec, ok := solveErr.Op.Op.(*pb.Op_Exec); ok {
rCtx := t.rCtx
getContainer := sync.OnceValues(func() (*build.Container, error) {
return build.NewContainer(c, rCtx, &build.InvokeConfig{})
})
for i, m := range exec.Exec.Mounts {
refs[m.Dest] = &mountReference{
getContainer: getContainer,
index: i,
}
}
}
}
}
// 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
}
if t.sourceInfoMap != nil {
t.def.Source = t.sourceInfoMap(t.def.Source)
}
for _, src := range t.def.Source.Infos {
t.sourceMap.Put(ctx, src.Filename, 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)
}
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 {
// Attempt to match the input to one of the mount destinations if we have
// an exec operation.
if exec, ok := op.Op.(*pb.Op_Exec); ok {
for _, m := range exec.Exec.Mounts {
if m.Input >= 0 && m.Input == int64(index) {
return m.Dest
}
}
}
// Is our input digest a source? Use the identifier if it is.
// That should give us something that is at least more user-friendly.
if op := t.ops[digest.Digest(input.Digest)]; op != nil && input.Index == 0 {
if source, ok := op.Op.(*pb.Op_Source); ok {
return source.Source.Identifier
}
}
// Use a default name that matches the input digest.
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
}
type mountReference struct {
getContainer func() (*build.Container, error)
index int
}
func (r *mountReference) ToState() (llb.State, error) {
return llb.State{}, errors.New("unimplemented, cannot use ToState with mount reference")
}
func (r *mountReference) Evaluate(ctx context.Context) error {
return nil
}
func (r *mountReference) ReadFile(ctx context.Context, req gateway.ReadRequest) ([]byte, error) {
ctr, err := r.getContainer()
if err != nil {
return nil, err
}
return ctr.ReadFile(ctx, gateway.ReadContainerRequest{
ReadRequest: req,
MountIndex: r.index,
})
}
func (r *mountReference) StatFile(ctx context.Context, req gateway.StatRequest) (*types.Stat, error) {
ctr, err := r.getContainer()
if err != nil {
return nil, err
}
return ctr.StatFile(ctx, gateway.StatContainerRequest{
StatRequest: req,
MountIndex: r.index,
})
}
func (r *mountReference) ReadDir(ctx context.Context, req gateway.ReadDirRequest) ([]*types.Stat, error) {
ctr, err := r.getContainer()
if err != nil {
return nil, err
}
return ctr.ReadDir(ctx, gateway.ReadDirContainerRequest{
ReadDirRequest: req,
MountIndex: r.index,
})
}