diff --git a/commands/service_debug.go b/commands/service_debug.go index d17c99622a6..31123cc9ae1 100644 --- a/commands/service_debug.go +++ b/commands/service_debug.go @@ -18,59 +18,317 @@ package commands import ( "context" "errors" + "fmt" + "io" "os" + "path/filepath" + "runtime" + "sync" + "sync/atomic" + "time" + "github.com/arduino/arduino-cli/commands/cmderrors" + "github.com/arduino/arduino-cli/commands/internal/instances" + "github.com/arduino/arduino-cli/internal/arduino/cores/packagemanager" "github.com/arduino/arduino-cli/internal/i18n" rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" + paths "github.com/arduino/go-paths-helper" + "github.com/djherbis/buffer" + "github.com/djherbis/nio/v3" + "github.com/sirupsen/logrus" + "google.golang.org/grpc/metadata" ) -// Debug returns a stream response that can be used to fetch data from the -// target. The first message passed through the `Debug` request must -// contain DebugRequest configuration params, not data. +type debugServer struct { + ctx context.Context + req atomic.Pointer[rpc.GetDebugConfigRequest] + in io.Reader + inSignal bool + inData bool + inEvent *sync.Cond + inLock sync.Mutex + out io.Writer + resultCB func(*rpc.DebugResponse_Result) + done chan bool +} + +func (s *debugServer) Send(resp *rpc.DebugResponse) error { + if len(resp.GetData()) > 0 { + if _, err := s.out.Write(resp.GetData()); err != nil { + return err + } + } + if res := resp.GetResult(); res != nil { + s.resultCB(res) + s.close() + } + return nil +} + +func (s *debugServer) Recv() (r *rpc.DebugRequest, e error) { + if conf := s.req.Swap(nil); conf != nil { + return &rpc.DebugRequest{DebugRequest: conf}, nil + } + + s.inEvent.L.Lock() + for !s.inSignal && !s.inData { + s.inEvent.Wait() + } + defer s.inEvent.L.Unlock() + + if s.inSignal { + s.inSignal = false + return &rpc.DebugRequest{SendInterrupt: true}, nil + } + + if s.inData { + s.inData = false + buff := make([]byte, 4096) + n, err := s.in.Read(buff) + if err != nil { + return nil, err + } + return &rpc.DebugRequest{Data: buff[:n]}, nil + } + + panic("invalid state in debug") +} + +func (s *debugServer) close() { + close(s.done) +} + +func (s *debugServer) Context() context.Context { return s.ctx } +func (s *debugServer) RecvMsg(m any) error { return nil } +func (s *debugServer) SendHeader(metadata.MD) error { return nil } +func (s *debugServer) SendMsg(m any) error { return nil } +func (s *debugServer) SetHeader(metadata.MD) error { return nil } +func (s *debugServer) SetTrailer(metadata.MD) {} + +// DebugServerToStreams creates a debug server that proxies the data to the given io streams. +// The GetDebugConfigRequest is used to configure the debbuger. sig is a channel that can be +// used to send os.Interrupt to the debug process. resultCB is a callback function that will +// receive the Debug result and closes the debug server. +func DebugServerToStreams( + ctx context.Context, + req *rpc.GetDebugConfigRequest, + in io.Reader, out io.Writer, + sig chan os.Signal, + resultCB func(*rpc.DebugResponse_Result), +) rpc.ArduinoCoreService_DebugServer { + server := &debugServer{ + ctx: ctx, + in: in, + out: out, + resultCB: resultCB, + done: make(chan bool), + } + serverIn, clientOut := nio.Pipe(buffer.New(32 * 1024)) + server.in = serverIn + server.inEvent = sync.NewCond(&server.inLock) + server.req.Store(req) + go func() { + for { + select { + case <-sig: + server.inEvent.L.Lock() + server.inSignal = true + server.inEvent.Broadcast() + server.inEvent.L.Unlock() + case <-server.done: + return + } + } + }() + go func() { + defer clientOut.Close() + buff := make([]byte, 4096) + for { + n, readErr := in.Read(buff) + + server.inEvent.L.Lock() + var writeErr error + if readErr == nil { + _, writeErr = clientOut.Write(buff[:n]) + } + server.inData = true + server.inEvent.Broadcast() + server.inEvent.L.Unlock() + if readErr != nil || writeErr != nil { + // exit on error + return + } + } + }() + return server +} + +// Debug starts a debugging session. The first message passed through the `Debug` request must +// contain DebugRequest configuration params and no data. func (s *arduinoCoreServerImpl) Debug(stream rpc.ArduinoCoreService_DebugServer) error { + // Utility functions + syncSend := NewSynchronizedSend(stream.Send) + sendResult := func(res *rpc.DebugResponse_Result) error { + return syncSend.Send(&rpc.DebugResponse{Message: &rpc.DebugResponse_Result_{Result: res}}) + } + sendData := func(data []byte) { + _ = syncSend.Send(&rpc.DebugResponse{Message: &rpc.DebugResponse_Data{Data: data}}) + } + // Grab the first message - msg, err := stream.Recv() + debugConfReqMsg, err := stream.Recv() if err != nil { return err } // Ensure it's a config message and not data - req := msg.GetDebugRequest() - if req == nil { + debugConfReq := debugConfReqMsg.GetDebugRequest() + if debugConfReq == nil { return errors.New(i18n.Tr("First message must contain debug request, not data")) } // Launch debug recipe attaching stdin and out to grpc streaming signalChan := make(chan os.Signal) defer close(signalChan) - outStream := feedStreamTo(func(data []byte) { - stream.Send(&rpc.DebugResponse{Message: &rpc.DebugResponse_Data{ - Data: data, - }}) - }) - resp, debugErr := Debug(stream.Context(), req, - consumeStreamFrom(func() ([]byte, error) { - command, err := stream.Recv() - if command.GetSendInterrupt() { + outStream := feedStreamTo(sendData) + defer outStream.Close() + inStream := consumeStreamFrom(func() ([]byte, error) { + for { + req, err := stream.Recv() + if err != nil { + return nil, err + } + if req.GetSendInterrupt() { signalChan <- os.Interrupt } - return command.GetData(), err - }), - outStream, - signalChan) - outStream.Close() - if debugErr != nil { - return debugErr - } - return stream.Send(resp) -} + if data := req.GetData(); len(data) > 0 { + return data, nil + } + } + }) + + pme, release, err := instances.GetPackageManagerExplorer(debugConfReq.GetInstance()) + if err != nil { + return err + } + defer release() + + // Exec debugger + commandLine, err := getCommandLine(debugConfReq, pme) + if err != nil { + return err + } + entry := logrus.NewEntry(logrus.StandardLogger()) + for i, param := range commandLine { + entry = entry.WithField(fmt.Sprintf("param%d", i), param) + } + entry.Debug("Executing debugger") + cmd, err := paths.NewProcess(pme.GetEnvVarsForSpawnedProcess(), commandLine...) + if err != nil { + return &cmderrors.FailedDebugError{Message: i18n.Tr("Cannot execute debug tool"), Cause: err} + } + in, err := cmd.StdinPipe() + if err != nil { + return sendResult(&rpc.DebugResponse_Result{Error: err.Error()}) + } + defer in.Close() + cmd.RedirectStdoutTo(io.Writer(outStream)) + cmd.RedirectStderrTo(io.Writer(outStream)) + if err := cmd.Start(); err != nil { + return sendResult(&rpc.DebugResponse_Result{Error: err.Error()}) + } -// GetDebugConfig return metadata about a debug session -func (s *arduinoCoreServerImpl) GetDebugConfig(ctx context.Context, req *rpc.GetDebugConfigRequest) (*rpc.GetDebugConfigResponse, error) { - return GetDebugConfig(ctx, req) + go func() { + for sig := range signalChan { + cmd.Signal(sig) + } + }() + go func() { + io.Copy(in, inStream) + time.Sleep(time.Second) + cmd.Kill() + }() + if err := cmd.Wait(); err != nil { + return sendResult(&rpc.DebugResponse_Result{Error: err.Error()}) + } + return sendResult(&rpc.DebugResponse_Result{}) } -// IsDebugSupported checks if debugging is supported for a given configuration -func (s *arduinoCoreServerImpl) IsDebugSupported(ctx context.Context, req *rpc.IsDebugSupportedRequest) (*rpc.IsDebugSupportedResponse, error) { - return IsDebugSupported(ctx, req) +// getCommandLine compose a debug command represented by a core recipe +func getCommandLine(req *rpc.GetDebugConfigRequest, pme *packagemanager.Explorer) ([]string, error) { + debugInfo, err := getDebugProperties(req, pme, false) + if err != nil { + return nil, err + } + + cmdArgs := []string{} + add := func(s string) { cmdArgs = append(cmdArgs, s) } + + // Add path to GDB Client to command line + var gdbPath *paths.Path + switch debugInfo.GetToolchain() { + case "gcc": + gdbexecutable := debugInfo.GetToolchainPrefix() + "-gdb" + if runtime.GOOS == "windows" { + gdbexecutable += ".exe" + } + gdbPath = paths.New(debugInfo.GetToolchainPath()).Join(gdbexecutable) + default: + return nil, &cmderrors.FailedDebugError{Message: i18n.Tr("Toolchain '%s' is not supported", debugInfo.GetToolchain())} + } + add(gdbPath.String()) + + // Set GDB interpreter (default value should be "console") + gdbInterpreter := req.GetInterpreter() + if gdbInterpreter == "" { + gdbInterpreter = "console" + } + add("--interpreter=" + gdbInterpreter) + if gdbInterpreter != "console" { + add("-ex") + add("set pagination off") + } + + // Add extra GDB execution commands + add("-ex") + add("set remotetimeout 5") + + // Extract path to GDB Server + switch debugInfo.GetServer() { + case "openocd": + var openocdConf rpc.DebugOpenOCDServerConfiguration + if err := debugInfo.GetServerConfiguration().UnmarshalTo(&openocdConf); err != nil { + return nil, err + } + + serverCmd := fmt.Sprintf(`target extended-remote | "%s"`, debugInfo.GetServerPath()) + + if cfg := openocdConf.GetScriptsDir(); cfg != "" { + serverCmd += fmt.Sprintf(` -s "%s"`, cfg) + } + + for _, script := range openocdConf.GetScripts() { + serverCmd += fmt.Sprintf(` --file "%s"`, script) + } + + serverCmd += ` -c "gdb_port pipe"` + serverCmd += ` -c "telnet_port 0"` + + add("-ex") + add(serverCmd) + + default: + return nil, &cmderrors.FailedDebugError{Message: i18n.Tr("GDB server '%s' is not supported", debugInfo.GetServer())} + } + + // Add executable + add(debugInfo.GetExecutable()) + + // Transform every path to forward slashes (on Windows some tools further + // escapes the command line so the backslash "\" gets in the way). + for i, param := range cmdArgs { + cmdArgs[i] = filepath.ToSlash(param) + } + + return cmdArgs, nil } diff --git a/commands/service_debug_config.go b/commands/service_debug_config.go index 393a81811e6..018ae4033e8 100644 --- a/commands/service_debug_config.go +++ b/commands/service_debug_config.go @@ -38,7 +38,7 @@ import ( ) // GetDebugConfig returns metadata to start debugging with the specified board -func GetDebugConfig(ctx context.Context, req *rpc.GetDebugConfigRequest) (*rpc.GetDebugConfigResponse, error) { +func (s *arduinoCoreServerImpl) GetDebugConfig(ctx context.Context, req *rpc.GetDebugConfigRequest) (*rpc.GetDebugConfigResponse, error) { pme, release, err := instances.GetPackageManagerExplorer(req.GetInstance()) if err != nil { return nil, err @@ -48,7 +48,7 @@ func GetDebugConfig(ctx context.Context, req *rpc.GetDebugConfigRequest) (*rpc.G } // IsDebugSupported checks if the given board/programmer configuration supports debugging. -func IsDebugSupported(ctx context.Context, req *rpc.IsDebugSupportedRequest) (*rpc.IsDebugSupportedResponse, error) { +func (s *arduinoCoreServerImpl) IsDebugSupported(ctx context.Context, req *rpc.IsDebugSupportedRequest) (*rpc.IsDebugSupportedResponse, error) { pme, release, err := instances.GetPackageManagerExplorer(req.GetInstance()) if err != nil { return nil, err diff --git a/commands/service_debug_run.go b/commands/service_debug_run.go deleted file mode 100644 index 94cdc43efa8..00000000000 --- a/commands/service_debug_run.go +++ /dev/null @@ -1,201 +0,0 @@ -// This file is part of arduino-cli. -// -// Copyright 2020 ARDUINO SA (http://www.arduino.cc/) -// -// This software is released under the GNU General Public License version 3, -// which covers the main part of arduino-cli. -// The terms of this license can be found at: -// https://www.gnu.org/licenses/gpl-3.0.en.html -// -// You can be released from the requirements of the above licenses by purchasing -// a commercial license. Buying such a license is mandatory if you want to -// modify or otherwise use the software for commercial activities involving the -// Arduino software without disclosing the source code of your own applications. -// To purchase a commercial license, send an email to license@arduino.cc. - -package commands - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "runtime" - "time" - - "github.com/arduino/arduino-cli/commands/cmderrors" - "github.com/arduino/arduino-cli/commands/internal/instances" - "github.com/arduino/arduino-cli/internal/arduino/cores/packagemanager" - "github.com/arduino/arduino-cli/internal/i18n" - rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" - "github.com/arduino/go-paths-helper" - "github.com/sirupsen/logrus" -) - -// Debug command launches a debug tool for a sketch. -// It also implements streams routing: -// gRPC In -> tool stdIn -// grpc Out <- tool stdOut -// grpc Out <- tool stdErr -// It also implements tool process lifecycle management -func Debug(ctx context.Context, req *rpc.GetDebugConfigRequest, inStream io.Reader, out io.Writer, interrupt <-chan os.Signal) (*rpc.DebugResponse, error) { - - // Get debugging command line to run debugger - pme, release, err := instances.GetPackageManagerExplorer(req.GetInstance()) - if err != nil { - return nil, err - } - defer release() - - commandLine, err := getCommandLine(req, pme) - if err != nil { - return nil, err - } - - for i, arg := range commandLine { - fmt.Printf("%2d: %s\n", i, arg) - } - - // Run Tool - entry := logrus.NewEntry(logrus.StandardLogger()) - for i, param := range commandLine { - entry = entry.WithField(fmt.Sprintf("param%d", i), param) - } - entry.Debug("Executing debugger") - - cmd, err := paths.NewProcess(pme.GetEnvVarsForSpawnedProcess(), commandLine...) - if err != nil { - return nil, &cmderrors.FailedDebugError{Message: i18n.Tr("Cannot execute debug tool"), Cause: err} - } - - // Get stdIn pipe from tool - in, err := cmd.StdinPipe() - if err != nil { - return &rpc.DebugResponse{Message: &rpc.DebugResponse_Result_{ - Result: &rpc.DebugResponse_Result{Error: err.Error()}, - }}, nil - } - defer in.Close() - - // Merge tool StdOut and StdErr to stream them in the io.Writer passed stream - cmd.RedirectStdoutTo(out) - cmd.RedirectStderrTo(out) - - // Start the debug command - if err := cmd.Start(); err != nil { - return &rpc.DebugResponse{Message: &rpc.DebugResponse_Result_{ - Result: &rpc.DebugResponse_Result{Error: err.Error()}, - }}, nil - } - - if interrupt != nil { - go func() { - for { - sig, ok := <-interrupt - if !ok { - break - } - cmd.Signal(sig) - } - }() - } - - go func() { - // Copy data from passed inStream into command stdIn - io.Copy(in, inStream) - // In any case, try process termination after a second to avoid leaving - // zombie process. - time.Sleep(time.Second) - cmd.Kill() - }() - - // Wait for process to finish - if err := cmd.Wait(); err != nil { - return &rpc.DebugResponse{Message: &rpc.DebugResponse_Result_{ - Result: &rpc.DebugResponse_Result{Error: err.Error()}, - }}, nil - } - return &rpc.DebugResponse{Message: &rpc.DebugResponse_Result_{ - Result: &rpc.DebugResponse_Result{}, - }}, nil -} - -// getCommandLine compose a debug command represented by a core recipe -func getCommandLine(req *rpc.GetDebugConfigRequest, pme *packagemanager.Explorer) ([]string, error) { - debugInfo, err := getDebugProperties(req, pme, false) - if err != nil { - return nil, err - } - - cmdArgs := []string{} - add := func(s string) { cmdArgs = append(cmdArgs, s) } - - // Add path to GDB Client to command line - var gdbPath *paths.Path - switch debugInfo.GetToolchain() { - case "gcc": - gdbexecutable := debugInfo.GetToolchainPrefix() + "-gdb" - if runtime.GOOS == "windows" { - gdbexecutable += ".exe" - } - gdbPath = paths.New(debugInfo.GetToolchainPath()).Join(gdbexecutable) - default: - return nil, &cmderrors.FailedDebugError{Message: i18n.Tr("Toolchain '%s' is not supported", debugInfo.GetToolchain())} - } - add(gdbPath.String()) - - // Set GDB interpreter (default value should be "console") - gdbInterpreter := req.GetInterpreter() - if gdbInterpreter == "" { - gdbInterpreter = "console" - } - add("--interpreter=" + gdbInterpreter) - if gdbInterpreter != "console" { - add("-ex") - add("set pagination off") - } - - // Add extra GDB execution commands - add("-ex") - add("set remotetimeout 5") - - // Extract path to GDB Server - switch debugInfo.GetServer() { - case "openocd": - var openocdConf rpc.DebugOpenOCDServerConfiguration - if err := debugInfo.GetServerConfiguration().UnmarshalTo(&openocdConf); err != nil { - return nil, err - } - - serverCmd := fmt.Sprintf(`target extended-remote | "%s"`, debugInfo.GetServerPath()) - - if cfg := openocdConf.GetScriptsDir(); cfg != "" { - serverCmd += fmt.Sprintf(` -s "%s"`, cfg) - } - - for _, script := range openocdConf.GetScripts() { - serverCmd += fmt.Sprintf(` --file "%s"`, script) - } - - serverCmd += ` -c "gdb_port pipe"` - serverCmd += ` -c "telnet_port 0"` - - add("-ex") - add(serverCmd) - - default: - return nil, &cmderrors.FailedDebugError{Message: i18n.Tr("GDB server '%s' is not supported", debugInfo.GetServer())} - } - - // Add executable - add(debugInfo.GetExecutable()) - - // Transform every path to forward slashes (on Windows some tools further - // escapes the command line so the backslash "\" gets in the way). - for i, param := range cmdArgs { - cmdArgs[i] = filepath.ToSlash(param) - } - - return cmdArgs, nil -} diff --git a/internal/cli/debug/debug.go b/internal/cli/debug/debug.go index 03c8dc8cfcb..84ac83c8cad 100644 --- a/internal/cli/debug/debug.go +++ b/internal/cli/debug/debug.go @@ -122,7 +122,7 @@ func runDebugCommand(ctx context.Context, srv rpc.ArduinoCoreServiceServer, args if printInfo { - if res, err := commands.GetDebugConfig(ctx, debugConfigRequested); err != nil { + if res, err := srv.GetDebugConfig(ctx, debugConfigRequested); err != nil { errcode := feedback.ErrBadArgument if errors.Is(err, &cmderrors.MissingProgrammerError{}) { errcode = feedback.ErrMissingProgrammer @@ -142,7 +142,10 @@ func runDebugCommand(ctx context.Context, srv rpc.ArduinoCoreServiceServer, args if err != nil { feedback.FatalError(err, feedback.ErrBadArgument) } - if _, err := commands.Debug(ctx, debugConfigRequested, in, out, ctrlc); err != nil { + + resultCB := func(dr *rpc.DebugResponse_Result) {} + stream := commands.DebugServerToStreams(ctx, debugConfigRequested, in, out, ctrlc, resultCB) + if err := srv.Debug(stream); err != nil { errcode := feedback.ErrGeneric if errors.Is(err, &cmderrors.MissingProgrammerError{}) { errcode = feedback.ErrMissingProgrammer diff --git a/internal/cli/debug/debug_check.go b/internal/cli/debug/debug_check.go index cb749a2da0e..ea64390b311 100644 --- a/internal/cli/debug/debug_check.go +++ b/internal/cli/debug/debug_check.go @@ -19,7 +19,6 @@ import ( "context" "os" - "github.com/arduino/arduino-cli/commands" "github.com/arduino/arduino-cli/internal/cli/arguments" "github.com/arduino/arduino-cli/internal/cli/feedback" "github.com/arduino/arduino-cli/internal/cli/feedback/result" @@ -61,7 +60,7 @@ func runDebugCheckCommand(ctx context.Context, srv rpc.ArduinoCoreServiceServer, feedback.FatalError(err, feedback.ErrBadArgument) } fqbn := fqbnArg.String() - resp, err := commands.IsDebugSupported(ctx, &rpc.IsDebugSupportedRequest{ + resp, err := srv.IsDebugSupported(ctx, &rpc.IsDebugSupportedRequest{ Instance: instance, Fqbn: fqbn, Port: port,