diff --git a/commands/build.go b/commands/build.go index 7e807016004a..aa58a6e04d71 100644 --- a/commands/build.go +++ b/commands/build.go @@ -398,10 +398,6 @@ func runBuild(ctx context.Context, dockerCli command.Cli, debugOpts debuggerOpti done := timeBuildCommand(mp, attributes) resp, inputs, retErr := runBuildWithOptions(ctx, dockerCli, opts, dbg, printer) - if err := printer.Wait(); retErr == nil { - retErr = err - } - done(retErr) if retErr != nil { return retErr @@ -462,12 +458,21 @@ func runBuildWithOptions(ctx context.Context, dockerCli command.Cli, opts *Build if err := dbg.Start(printer, opts); err != nil { return nil, nil, err } - defer dbg.Stop() + defer func() { dbg.Stop(retErr) }() bh = dbg.Handler() dockerCli.SetIn(nil) } + // Ensure messages sent to the printer are flushed before the debugger completes. + // This prevents late messages from not being sent because the connection was + // terminated before completion of the debugger. + defer func() { + if err := printer.Wait(); retErr == nil { + retErr = err + } + }() + in := dockerCli.In() for { resp, inputs, err := RunBuild(ctx, dockerCli, opts, in, printer, &bh) diff --git a/commands/dap.go b/commands/dap.go index 0677400376dc..6114870b1633 100644 --- a/commands/dap.go +++ b/commands/dap.go @@ -91,9 +91,9 @@ func (d *adapterProtocolDebugger) Start(printer *progress.Printer, opts *BuildOp return nil } -func (d *adapterProtocolDebugger) Stop() error { +func (d *adapterProtocolDebugger) Stop(retErr error) error { defer d.conn.Close() - return d.Adapter.Stop() + return d.Adapter.Stop(retErr) } func dapAttachCmd() *cobra.Command { diff --git a/commands/debug.go b/commands/debug.go index ff331903dfc6..3238cef983a6 100644 --- a/commands/debug.go +++ b/commands/debug.go @@ -41,7 +41,7 @@ type debuggerOptions interface { type debuggerInstance interface { Start(printer *progress.Printer, opts *BuildOptions) error Handler() build.Handler - Stop() error + Stop(retErr error) error Out() io.Writer } @@ -98,7 +98,7 @@ func (d *monitorDebuggerInstance) Handler() build.Handler { return d.m.Handler() } -func (d *monitorDebuggerInstance) Stop() error { +func (d *monitorDebuggerInstance) Stop(_ error) error { return d.m.Close() } diff --git a/dap/adapter.go b/dap/adapter.go index f7ac09c15322..761478c983ce 100644 --- a/dap/adapter.go +++ b/dap/adapter.go @@ -83,7 +83,7 @@ func (d *Adapter[C]) Start(conn Conn) (C, error) { return resp.Config, resp.Error } -func (d *Adapter[C]) Stop() error { +func (d *Adapter[C]) Stop(retErr error) error { if d.eg == nil { return nil } @@ -94,15 +94,27 @@ func (d *Adapter[C]) Stop() error { Event: "terminated", }, } - // TODO: detect exit code from threads - // c.C() <- &dap.ExitedEvent{ - // Event: dap.Event{ - // Event: "exited", - // }, - // Body: dap.ExitedEventBody{ - // ExitCode: exitCode, - // }, - // } + + // Send an exit code based on the returned error. + // Any error results in sending an exit code of 1 while + // no error sends zero for success. + // + // The exited event is sent after the terminated event. + // See the specification overview diagram on the bottom of the page + // for a detailed flowchart. + // https://microsoft.github.io/debug-adapter-protocol/overview + exitCode := 0 + if retErr != nil { + exitCode = 1 + } + c.C() <- &dap.ExitedEvent{ + Event: dap.Event{ + Event: "exited", + }, + Body: dap.ExitedEventBody{ + ExitCode: exitCode, + }, + } }) d.srv.Stop() diff --git a/dap/adapter_test.go b/dap/adapter_test.go index 31a188548b41..386e038c9a2e 100644 --- a/dap/adapter_test.go +++ b/dap/adapter_test.go @@ -253,7 +253,7 @@ func NewTestAdapter[C LaunchConfig](t *testing.T) (*Adapter[C], Conn, *daptest.C client := daptest.NewClient(clientConn) t.Cleanup(func() { client.Close() }) - t.Cleanup(func() { adapter.Stop() }) + t.Cleanup(func() { adapter.Stop(nil) }) return adapter, srvConn, client } diff --git a/tests/dap_build.go b/tests/dap_build.go index fba4b3d0cf0b..5e132e2a6866 100644 --- a/tests/dap_build.go +++ b/tests/dap_build.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path" + "path/filepath" "runtime" "slices" "strings" @@ -87,6 +88,7 @@ var dapBuildTests = []func(t *testing.T, sb integration.Sandbox){ testDapBuildStepOut, testDapBuildVariables, testDapBuildDeferredEval, + testDapBuildExitedEvent, } func testDapBuild(t *testing.T, sb integration.Sandbox) { @@ -914,6 +916,73 @@ func testDapBuildDeferredEval(t *testing.T, sb integration.Sandbox) { require.ErrorAs(t, done(true), &exitErr) } +func testDapBuildExitedEvent(t *testing.T, sb integration.Sandbox) { + t.Run("success", func(t *testing.T) { + dir := createTestProject(t) + client, done, err := dapBuildCmd(t, sb) + require.NoError(t, err) + + ch := make(chan *dap.ExitedEvent, 1) + client.RegisterEvent("exited", func(em dap.EventMessage) { + ch <- em.(*dap.ExitedEvent) + close(ch) + }) + + // Project should just build normally. + doLaunch(t, client, commands.LaunchConfig{ + Dockerfile: path.Join(dir, "Dockerfile"), + ContextPath: dir, + }) + + select { + case exited := <-ch: + require.Equal(t, 0, exited.Body.ExitCode) + case <-time.After(5 * time.Second): + require.Fail(t, "timeout reached") + } + + require.NoError(t, done(true)) + }) + + t.Run("failure", func(t *testing.T) { + dir := createTestProject(t) + client, done, err := dapBuildCmd(t, sb) + require.NoError(t, err) + + ch := make(chan *dap.ExitedEvent, 1) + client.RegisterEvent("exited", func(em dap.EventMessage) { + ch <- em.(*dap.ExitedEvent) + close(ch) + }) + + // Delete foo from the test project so this will fail. + err = os.Remove(filepath.Join(dir, "foo")) + require.NoError(t, err) + + interruptCh := pollInterruptEvents(client) + doLaunch(t, client, commands.LaunchConfig{ + Dockerfile: path.Join(dir, "Dockerfile"), + ContextPath: dir, + }) + + // We will hit an interrupt because of the failure. + stopped := waitForInterrupt[*dap.StoppedEvent](t, interruptCh) + require.Equal(t, "exception", stopped.Body.Reason) + + // Continue execution which should trigger the exited event. + doNext(t, client, stopped.Body.ThreadId) + select { + case exited := <-ch: + require.NotEqual(t, 0, exited.Body.ExitCode) + case <-time.After(time.Second): + require.Fail(t, "timeout reached") + } + + var exitErr *exec.ExitError + require.ErrorAs(t, done(false), &exitErr) + }) +} + func doLaunch(t *testing.T, client *daptest.Client, config commands.LaunchConfig, bps ...dap.SourceBreakpoint) { t.Helper()