From 8e356c3454d2be1cbf79f84aa757a9a679fd0091 Mon Sep 17 00:00:00 2001 From: "Jonathan A. Sternberg" Date: Mon, 28 Jul 2025 09:52:30 -0500 Subject: [PATCH] dap: filesystem inspection when paused on a digest Add a file explorer to the debugger that allows exploring the filesystem of the current container. It will show directory contents, file contents, and symlink destinations. It will also show the file mode associated with a file. The file explorer defaults to marking itself as an expensive operation so the debugger doesn't automatically retrieve the information. Signed-off-by: Jonathan A. Sternberg --- dap/thread.go | 19 ++++- dap/variables.go | 217 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 214 insertions(+), 22 deletions(-) diff --git a/dap/thread.go b/dap/thread.go index 2f3b9f0e9..5f87b024c 100644 --- a/dap/thread.go +++ b/dap/thread.go @@ -50,6 +50,7 @@ type thread struct { mu sync.Mutex // Attributes set when a thread is paused. + cancel context.CancelCauseFunc rCtx *build.ResultHandle curPos digest.Digest stackTrace []int32 @@ -264,7 +265,10 @@ func (t *thread) pause(c Context, ref gateway.Reference, err error, pos *step, e } } } - t.collectStackTrace(pos) + + ctx, cancel := context.WithCancelCause(c) + t.collectStackTrace(ctx, pos, ref) + t.cancel = cancel event.ThreadId = t.id c.C() <- &dap.StoppedEvent{ @@ -490,20 +494,27 @@ func (t *thread) solve(ctx context.Context, target digest.Digest) (gateway.Refer } func (t *thread) releaseState() { + if t.cancel != nil { + t.cancel(context.Canceled) + t.cancel = nil + } if t.rCtx != nil { t.rCtx.Done() t.rCtx = nil } + for _, f := range t.frames { + f.ResetVars() + } t.stackTrace = t.stackTrace[:0] t.variables.Reset() } -func (t *thread) collectStackTrace(pos *step) { +func (t *thread) collectStackTrace(ctx context.Context, pos *step, ref gateway.Reference) { for pos != nil { frame := pos.frame - frame.ExportVars(t.variables) + frame.ExportVars(ctx, ref, t.variables) t.stackTrace = append(t.stackTrace, int32(frame.Id)) - pos = pos.out + pos, ref = pos.out, nil } } diff --git a/dap/variables.go b/dap/variables.go index ca4a29718..b5bdb90aa 100644 --- a/dap/variables.go +++ b/dap/variables.go @@ -1,16 +1,22 @@ package dap import ( + "context" "fmt" + "io/fs" "path/filepath" "strconv" "strings" "sync" "sync/atomic" + "time" + "unicode/utf8" "github.com/google/go-dap" "github.com/moby/buildkit/client/llb" + gateway "github.com/moby/buildkit/frontend/gateway/client" "github.com/moby/buildkit/solver/pb" + "github.com/tonistiigi/fsutil/types" ) type frame struct { @@ -45,29 +51,34 @@ func (f *frame) fillLocation(def *llb.Definition, loc *pb.Locations, ws string) } } -func (f *frame) ExportVars(refs *variableReferences) { +func (f *frame) ExportVars(ctx context.Context, ref gateway.Reference, refs *variableReferences) { f.fillVarsFromOp(f.op, refs) + if ref != nil { + f.fillVarsFromResult(ctx, ref, refs) + } +} + +func (f *frame) ResetVars() { + f.scopes = nil } func (f *frame) fillVarsFromOp(op *pb.Op, refs *variableReferences) { - f.scopes = []dap.Scope{ - { - Name: "Arguments", - PresentationHint: "arguments", - VariablesReference: refs.New(func() []dap.Variable { - var vars []dap.Variable - if op.Platform != nil { - vars = append(vars, platformVars(op.Platform, refs)) - } + f.scopes = append(f.scopes, dap.Scope{ + Name: "Arguments", + PresentationHint: "arguments", + VariablesReference: refs.New(func() []dap.Variable { + var vars []dap.Variable + if op.Platform != nil { + vars = append(vars, platformVars(op.Platform, refs)) + } - switch op := op.Op.(type) { - case *pb.Op_Exec: - vars = append(vars, execOpVars(op.Exec, refs)) - } - return vars - }), - }, - } + switch op := op.Op.(type) { + case *pb.Op_Exec: + vars = append(vars, execOpVars(op.Exec, refs)) + } + return vars + }), + }) } func platformVars(platform *pb.Platform, refs *variableReferences) dap.Variable { @@ -159,6 +170,148 @@ func execOpVars(exec *pb.ExecOp, refs *variableReferences) dap.Variable { } } +func (f *frame) fillVarsFromResult(ctx context.Context, ref gateway.Reference, refs *variableReferences) { + f.scopes = append(f.scopes, dap.Scope{ + Name: "File Explorer", + PresentationHint: "locals", + VariablesReference: refs.New(func() []dap.Variable { + return fsVars(ctx, ref, "/", refs) + }), + Expensive: true, + }) +} + +func fsVars(ctx context.Context, ref gateway.Reference, path string, vars *variableReferences) []dap.Variable { + files, err := ref.ReadDir(ctx, gateway.ReadDirRequest{ + Path: path, + }) + if err != nil { + return []dap.Variable{ + { + Name: "error", + Value: err.Error(), + }, + } + } + + paths := make([]dap.Variable, len(files)) + for i, file := range files { + stat := statf(file) + fv := dap.Variable{ + Name: file.Path, + } + + fullpath := filepath.Join(path, file.Path) + if file.IsDir() { + fv.Name += "/" + fv.VariablesReference = vars.New(func() []dap.Variable { + dvar := dap.Variable{ + Name: ".", + Value: statf(file), + VariablesReference: vars.New(func() []dap.Variable { + return statVars(file) + }), + } + return append([]dap.Variable{dvar}, fsVars(ctx, ref, fullpath, vars)...) + }) + fv.Value = "" + } else { + fv.Value = stat + fv.VariablesReference = vars.New(func() (dvars []dap.Variable) { + if fs.FileMode(file.Mode).IsRegular() { + // Regular file so display a small blurb of the file. + dvars = append(dvars, fileVars(ctx, ref, fullpath)...) + } + return append(dvars, statVars(file)...) + }) + } + paths[i] = fv + } + return paths +} + +func statf(st *types.Stat) string { + mode := fs.FileMode(st.Mode) + modTime := time.Unix(0, st.ModTime).UTC() + return fmt.Sprintf("%s %d:%d %s", mode, st.Uid, st.Gid, modTime.Format("Jan 2 15:04:05 2006")) +} + +func fileVars(ctx context.Context, ref gateway.Reference, fullpath string) []dap.Variable { + b, err := ref.ReadFile(ctx, gateway.ReadRequest{ + Filename: fullpath, + Range: &gateway.FileRange{Length: 512}, + }) + + var ( + data string + dataErr error + ) + if err != nil { + data = err.Error() + } else if isBinaryData(b) { + data = "binary data" + } else { + if len(b) == 512 { + // Get the remainder of the file. + remaining, err := ref.ReadFile(ctx, gateway.ReadRequest{ + Filename: fullpath, + Range: &gateway.FileRange{Offset: 512}, + }) + if err != nil { + dataErr = err + } else { + b = append(b, remaining...) + } + } + data = string(b) + } + + dvars := []dap.Variable{ + { + Name: "data", + Value: data, + }, + } + if dataErr != nil { + dvars = append(dvars, dap.Variable{ + Name: "dataError", + Value: dataErr.Error(), + }) + } + return dvars +} + +func statVars(st *types.Stat) (vars []dap.Variable) { + if st.Linkname != "" { + vars = append(vars, dap.Variable{ + Name: "linkname", + Value: st.Linkname, + }) + } + + mode := fs.FileMode(st.Mode) + modTime := time.Unix(0, st.ModTime).UTC() + vars = append(vars, []dap.Variable{ + { + Name: "mode", + Value: mode.String(), + }, + { + Name: "uid", + Value: strconv.FormatUint(uint64(st.Uid), 10), + }, + { + Name: "gid", + Value: strconv.FormatUint(uint64(st.Gid), 10), + }, + { + Name: "mtime", + Value: modTime.Format("Jan 2 15:04:05 2006"), + }, + }...) + return vars +} + func (f *frame) Scopes() []dap.Scope { if f.scopes == nil { return []dap.Scope{} @@ -213,6 +366,34 @@ func (v *variableReferences) Reset() { v.nextID.Store(0) } +// isBinaryData uses heuristics to determine if the file +// is binary. Algorithm taken from this blog post: +// https://eli.thegreenplace.net/2011/10/19/perls-guess-if-file-is-text-or-binary-implemented-in-python/ +func isBinaryData(b []byte) bool { + odd := 0 + for i := 0; i < len(b); i++ { + c := b[i] + if c == 0 { + return true + } + + isHighBit := c&128 > 0 + if !isHighBit { + if c < 32 && c != '\n' && c != '\t' { + odd++ + } + } else { + r, sz := utf8.DecodeRune(b) + if r != utf8.RuneError && sz > 1 { + i += sz - 1 + continue + } + odd++ + } + } + return float64(odd)/float64(len(b)) > .3 +} + func brief(s string) string { if len(s) >= 64 { return s[:60] + " ..."