Skip to content

Commit 869fc6b

Browse files
committed
dap: make exec shell persistent across the build
Invoking the shell will cause it to persist across the entire build and to re-execute whenever the builder pauses at another location again. This still requires using `exec` to launch the shell. Launching by frame id is also removed since it no longer applies to this version. Signed-off-by: Jonathan A. Sternberg <[email protected]>
1 parent 46463d9 commit 869fc6b

File tree

5 files changed

+352
-62
lines changed

5 files changed

+352
-62
lines changed

build/result.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
package build
22

33
import (
4+
"cmp"
45
"context"
56
_ "crypto/sha256" // ensure digests can be computed
67
"encoding/json"
78
"io"
9+
iofs "io/fs"
10+
"path/filepath"
11+
"slices"
12+
"strings"
813
"sync"
914

1015
"github.com/moby/buildkit/exporter/containerimage/exptypes"
@@ -14,6 +19,7 @@ import (
1419
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
1520
"github.com/pkg/errors"
1621
"github.com/sirupsen/logrus"
22+
"github.com/tonistiigi/fsutil/types"
1723
)
1824

1925
// NewResultHandle stores a gateway client, gateway reference, and the error from
@@ -75,6 +81,36 @@ func (r *ResultHandle) NewContainer(ctx context.Context, cfg *InvokeConfig) (gat
7581
return r.gwClient.NewContainer(ctx, req)
7682
}
7783

84+
func (r *ResultHandle) StatFile(ctx context.Context, fpath string, cfg *InvokeConfig) (*types.Stat, error) {
85+
containerCfg, err := r.getContainerConfig(cfg)
86+
if err != nil {
87+
return nil, err
88+
}
89+
90+
candidateMounts := make([]gateway.Mount, 0, len(containerCfg.Mounts))
91+
for _, m := range containerCfg.Mounts {
92+
if strings.HasPrefix(fpath, m.Dest) {
93+
candidateMounts = append(candidateMounts, m)
94+
}
95+
}
96+
if len(candidateMounts) == 0 {
97+
return nil, iofs.ErrNotExist
98+
}
99+
100+
slices.SortFunc(candidateMounts, func(a, b gateway.Mount) int {
101+
return cmp.Compare(len(a.Dest), len(b.Dest))
102+
})
103+
104+
m := candidateMounts[len(candidateMounts)-1]
105+
relpath, err := filepath.Rel(m.Dest, fpath)
106+
if err != nil {
107+
return nil, err
108+
}
109+
110+
req := gateway.StatRequest{Path: filepath.ToSlash(relpath)}
111+
return m.Ref.StatFile(ctx, req)
112+
}
113+
78114
func (r *ResultHandle) getContainerConfig(cfg *InvokeConfig) (containerCfg gateway.NewContainerRequest, _ error) {
79115
if r.ref != nil && r.solveErr == nil {
80116
logrus.Debugf("creating container from successful build")

dap/adapter.go

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,14 @@ type Adapter[C LaunchConfig] struct {
3838
threadsMu sync.RWMutex
3939
nextThreadID int
4040

41+
sharedState
42+
}
43+
44+
type sharedState struct {
4145
breakpointMap *breakpointMap
42-
sourceMap sourceMap
46+
sourceMap *sourceMap
4347
idPool *idPool
48+
sh *shell
4449
}
4550

4651
func New[C LaunchConfig]() *Adapter[C] {
@@ -51,8 +56,12 @@ func New[C LaunchConfig]() *Adapter[C] {
5156
evaluateReqCh: make(chan *evaluateRequest),
5257
threads: make(map[int]*thread),
5358
nextThreadID: 1,
54-
breakpointMap: newBreakpointMap(),
55-
idPool: new(idPool),
59+
sharedState: sharedState{
60+
breakpointMap: newBreakpointMap(),
61+
sourceMap: new(sourceMap),
62+
idPool: new(idPool),
63+
sh: newShell(),
64+
},
5665
}
5766
d.srv = NewServer(d.dapHandler())
5867
return d
@@ -233,12 +242,10 @@ func (d *Adapter[C]) newThread(ctx Context, name string) (t *thread) {
233242
d.threadsMu.Lock()
234243
id := d.nextThreadID
235244
t = &thread{
236-
id: id,
237-
name: name,
238-
sourceMap: &d.sourceMap,
239-
breakpointMap: d.breakpointMap,
240-
idPool: d.idPool,
241-
variables: newVariableReferences(),
245+
id: id,
246+
name: name,
247+
sharedState: d.sharedState,
248+
variables: newVariableReferences(),
242249
}
243250
d.threads[t.id] = t
244251
d.nextThreadID++
@@ -261,20 +268,6 @@ func (d *Adapter[C]) getThread(id int) (t *thread) {
261268
return t
262269
}
263270

264-
func (d *Adapter[C]) getFirstThread() (t *thread) {
265-
d.threadsMu.Lock()
266-
defer d.threadsMu.Unlock()
267-
268-
for _, thread := range d.threads {
269-
if thread.isPaused() {
270-
if t == nil || thread.id < t.id {
271-
t = thread
272-
}
273-
}
274-
}
275-
return t
276-
}
277-
278271
func (d *Adapter[C]) deleteThread(ctx Context, t *thread) {
279272
d.threadsMu.Lock()
280273
if t := d.threads[t.id]; t != nil {

dap/debug_shell.go

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
package dap
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"io/fs"
8+
"net"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"sync"
13+
14+
"github.com/docker/buildx/build"
15+
"github.com/docker/buildx/util/ioset"
16+
"github.com/docker/cli/cli-plugins/metadata"
17+
"github.com/google/go-dap"
18+
"github.com/pkg/errors"
19+
"golang.org/x/sync/errgroup"
20+
"golang.org/x/sync/semaphore"
21+
)
22+
23+
type shell struct {
24+
SocketPath string
25+
fwd *ioset.Forwarder
26+
27+
once sync.Once
28+
err error
29+
30+
l net.Listener
31+
eg *errgroup.Group
32+
sem *semaphore.Weighted
33+
34+
connected chan struct{}
35+
}
36+
37+
func newShell() *shell {
38+
return &shell{
39+
fwd: ioset.NewForwarder(),
40+
sem: semaphore.NewWeighted(1),
41+
connected: make(chan struct{}),
42+
}
43+
}
44+
45+
// Init initializes the shell for connections on the client side.
46+
// Attach will block until the terminal has been initialized.
47+
func (s *shell) Init() error {
48+
return s.listen()
49+
}
50+
51+
func (s *shell) listen() error {
52+
s.once.Do(func() {
53+
var dir string
54+
dir, s.err = os.MkdirTemp("", "buildx-dap-exec")
55+
if s.err != nil {
56+
return
57+
}
58+
defer func() {
59+
if s.err != nil {
60+
os.RemoveAll(dir)
61+
}
62+
}()
63+
s.SocketPath = filepath.Join(dir, "s.sock")
64+
65+
s.l, s.err = net.Listen("unix", s.SocketPath)
66+
if s.err != nil {
67+
return
68+
}
69+
70+
s.eg, _ = errgroup.WithContext(context.Background())
71+
s.eg.Go(func() error {
72+
conn, err := s.l.Accept()
73+
if err != nil {
74+
return err
75+
}
76+
writeLine(conn, "Attached to build process.")
77+
78+
// Set the input of the forwarder to the connection.
79+
s.fwd.SetIn(&ioset.In{
80+
Stdin: io.NopCloser(conn),
81+
Stdout: conn,
82+
Stderr: nopCloser{conn},
83+
})
84+
close(s.connected)
85+
86+
// Start a background goroutine to politely refuse any subsequent connections.
87+
for {
88+
conn, err := s.l.Accept()
89+
if err != nil {
90+
return nil
91+
}
92+
writeLine(conn, "Error: Already connected to exec instance.")
93+
conn.Close()
94+
}
95+
})
96+
})
97+
return s.err
98+
}
99+
100+
// Attach will attach the given thread to the shell.
101+
// Only one container can attach to a shell at any given time.
102+
// Other attaches will block until the context is canceled or it is
103+
// able to reserve the shell for its own use.
104+
//
105+
// This method is intended to be called by paused threads.
106+
func (s *shell) Attach(ctx context.Context, t *thread) error {
107+
rCtx := t.rCtx
108+
if rCtx == nil {
109+
return nil
110+
}
111+
112+
var f dap.StackFrame
113+
if len(t.stackTrace) > 0 {
114+
f = t.frames[t.stackTrace[0]].StackFrame
115+
}
116+
117+
cfg := &build.InvokeConfig{Tty: true}
118+
if len(cfg.Entrypoint) == 0 && len(cfg.Cmd) == 0 {
119+
cfg.Entrypoint = []string{"/bin/sh"} // launch shell by default
120+
cfg.Cmd = []string{}
121+
cfg.NoCmd = false
122+
}
123+
return s.attach(ctx, f, rCtx, cfg)
124+
}
125+
126+
func (s *shell) attach(ctx context.Context, f dap.StackFrame, rCtx *build.ResultHandle, cfg *build.InvokeConfig) (retErr error) {
127+
select {
128+
case <-s.connected:
129+
case <-ctx.Done():
130+
return context.Cause(ctx)
131+
}
132+
133+
in, out := ioset.Pipe()
134+
defer in.Close()
135+
defer out.Close()
136+
137+
s.fwd.SetOut(&out)
138+
defer s.fwd.SetOut(nil)
139+
140+
// Check if the entrypoint is executable. If it isn't, don't bother
141+
// trying to invoke.
142+
if !s.canInvoke(ctx, in.Stdout, rCtx, cfg) {
143+
writeLine(in.Stdout, "Waiting for build container...")
144+
return nil
145+
}
146+
147+
if err := s.sem.Acquire(ctx, 1); err != nil {
148+
return err
149+
}
150+
defer s.sem.Release(1)
151+
152+
ctr, err := build.NewContainer(ctx, rCtx, cfg)
153+
if err != nil {
154+
return err
155+
}
156+
defer ctr.Cancel()
157+
158+
writeLineF(in.Stdout, "Running %s in build container from line %d.",
159+
strings.Join(append(cfg.Entrypoint, cfg.Cmd...), " "),
160+
f.Line,
161+
)
162+
163+
writeLine(in.Stdout, "Changes to the container will be reset after the next step is executed.")
164+
err = ctr.Exec(ctx, cfg, in.Stdin, in.Stdout, in.Stderr)
165+
166+
// Send newline to properly terminate the output.
167+
writeLine(in.Stdout, "")
168+
169+
return err
170+
}
171+
172+
func (s *shell) canInvoke(ctx context.Context, w io.Writer, rCtx *build.ResultHandle, cfg *build.InvokeConfig) bool {
173+
var cmd string
174+
if len(cfg.Entrypoint) > 0 {
175+
cmd = cfg.Entrypoint[0]
176+
} else if len(cfg.Cmd) > 0 {
177+
cmd = cfg.Cmd[0]
178+
}
179+
180+
if cmd == "" {
181+
return false
182+
}
183+
184+
st, err := rCtx.StatFile(ctx, cmd, cfg)
185+
if err != nil {
186+
writeLineF(w, "stat error: %s", err)
187+
return false
188+
}
189+
190+
mode := fs.FileMode(st.Mode)
191+
if !mode.IsRegular() {
192+
writeLineF(w, "not a regular file: %s", mode)
193+
return false
194+
}
195+
if mode&0111 == 0 {
196+
writeLineF(w, "mode is not executable: %s", mode)
197+
return false
198+
}
199+
return true
200+
}
201+
202+
// SendRunInTerminalRequest will send the request to the client to attach to
203+
// the socket path that was created by Init. This is intended to be run
204+
// from the adapter and interact directly with the client.
205+
func (s *shell) SendRunInTerminalRequest(ctx Context) error {
206+
// TODO: this should work in standalone mode too.
207+
docker := os.Getenv(metadata.ReexecEnvvar)
208+
req := &dap.RunInTerminalRequest{
209+
Request: dap.Request{
210+
Command: "runInTerminal",
211+
},
212+
Arguments: dap.RunInTerminalRequestArguments{
213+
Kind: "integrated",
214+
Args: []string{docker, "buildx", "dap", "attach", s.SocketPath},
215+
Env: map[string]any{
216+
"BUILDX_EXPERIMENTAL": "1",
217+
},
218+
},
219+
}
220+
221+
resp := ctx.Request(req)
222+
if !resp.GetResponse().Success {
223+
return errors.New(resp.GetResponse().Message)
224+
}
225+
return nil
226+
}
227+
228+
type nopCloser struct {
229+
io.Writer
230+
}
231+
232+
func (nopCloser) Close() error {
233+
return nil
234+
}
235+
236+
func writeLine(w io.Writer, msg string) {
237+
if os.PathSeparator == '\\' {
238+
fmt.Fprint(w, msg+"\r\n")
239+
} else {
240+
fmt.Fprintln(w, msg)
241+
}
242+
}
243+
244+
func writeLineF(w io.Writer, format string, a ...any) {
245+
if os.PathSeparator == '\\' {
246+
fmt.Fprintf(w, format+"\r\n", a...)
247+
} else {
248+
fmt.Fprintf(w, format+"\n", a...)
249+
}
250+
}

0 commit comments

Comments
 (0)