diff --git a/cmd/xgo/runtime_gen/core/version.go b/cmd/xgo/runtime_gen/core/version.go index 8093db68..4f249e53 100755 --- a/cmd/xgo/runtime_gen/core/version.go +++ b/cmd/xgo/runtime_gen/core/version.go @@ -7,8 +7,8 @@ import ( ) const VERSION = "1.0.37" -const REVISION = "310d0d44809c8f2ad26761138fb8eb3cc4db75c9+1" -const NUMBER = 238 +const REVISION = "62c6c037c1f57c371e227dd1bb8b8e141367f1c6+1" +const NUMBER = 239 // these fields will be filled by compiler const XGO_VERSION = "" diff --git a/cmd/xgo/test-explorer/config.go b/cmd/xgo/test-explorer/config.go index 08a72109..8a5cb3d1 100644 --- a/cmd/xgo/test-explorer/config.go +++ b/cmd/xgo/test-explorer/config.go @@ -36,6 +36,13 @@ func (c *TestConfig) CmdEnv() []string { return env } +func (c *TestConfig) GetGoCmd() string { + if c.GoCmd != "" { + return c.GoCmd + } + return "go" +} + type GoConfig struct { Min string `json:"min"` Max string `json:"max"` diff --git a/cmd/xgo/test-explorer/debug.go b/cmd/xgo/test-explorer/debug.go new file mode 100644 index 00000000..394be293 --- /dev/null +++ b/cmd/xgo/test-explorer/debug.go @@ -0,0 +1,221 @@ +package test_explorer + +import ( + "bufio" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/xhd2015/xgo/support/cmd" + "github.com/xhd2015/xgo/support/fileutil" + "github.com/xhd2015/xgo/support/netutil" + "github.com/xhd2015/xgo/support/session" + "github.com/xhd2015/xgo/support/strutil" +) + +type DebugRequest struct { + Item *TestingItem `json:"item"` +} +type DebugResponse struct { + ID string `json:"id"` +} + +type DebugPollRequest struct { + ID string `json:"id"` +} + +type DebugPollResponse struct { + Events []*TestingItemEvent `json:"events"` +} +type DebugDestroyRequest struct { + ID string `json:"id"` +} + +func setupDebugHandler(server *http.ServeMux, projectDir string, getTestConfig func() (*TestConfig, error)) { + sessionManager := session.NewSessionManager() + + server.HandleFunc("/debug", func(w http.ResponseWriter, r *http.Request) { + netutil.SetCORSHeaders(w) + netutil.HandleJSON(w, r, func(ctx context.Context, r *http.Request) (interface{}, error) { + var req *DebugRequest + err := parseBody(r.Body, &req) + if err != nil { + return nil, err + } + if req == nil || req.Item == nil || req.Item.File == "" { + return nil, netutil.ParamErrorf("requires file") + } + + file := req.Item.File + isFile, err := fileutil.IsFile(file) + if err != nil { + return nil, err + } + if !isFile { + return nil, fmt.Errorf("cannot debug multiple tests") + } + absDir, err := filepath.Abs(projectDir) + if err != nil { + return nil, err + } + + relPath, err := filepath.Rel(absDir, file) + if err != nil { + return nil, err + } + + config, err := getTestConfig() + if err != nil { + return nil, err + } + + id, session, err := sessionManager.Start() + if err != nil { + return nil, err + } + + pr, pw := io.Pipe() + + // go func() { xxx } + // - build with gcflags="all=-N -l" + // - start dlv + // - output prompt + go func() { + defer session.SendEvents(&TestingItemEvent{ + Event: Event_TestEnd, + }) + debug := func(projectDir string, file string, stdout io.Writer, stderr io.Writer) error { + goCmd := config.GetGoCmd() + tmpDir, err := os.MkdirTemp("", "go-test-debug") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + binName := "debug.bin" + baseName := filepath.Base(file) + if baseName != "" { + binName = baseName + "-" + binName + } + + // TODO: find a way to automatically set breakpoint + // dlvInitFile := filepath.Join(tmpDir, "dlv-init.txt") + // err = ioutil.WriteFile(dlvInitFile, []byte(fmt.Sprintf("break %s:%d\n", file, req.Item.Line)), 0755) + // if err != nil { + // return err + // } + relPathDir := filepath.Dir(relPath) + tmpBin := filepath.Join(tmpDir, binName) + err = cmd.Dir(projectDir).Debug().Stderr(stderr).Stdout(stdout).Run(goCmd, "test", "-c", "-o", tmpBin, "-gcflags=all=-N -l", "./"+relPathDir) + if err != nil { + return err + } + err = netutil.ServePort(2345, true, 500*time.Millisecond, func(port int) { + // user need to set breakpoint explicitly + fmt.Fprintf(stderr, "dlv listen on localhost:%d\n", port) + fmt.Fprintf(stderr, "Debug with IDEs:\n") + fmt.Fprintf(stderr, " > VSCode: add the following config to .vscode/launch.json configurations:") + fmt.Fprintf(stderr, "\n%s\n", strutil.IndentLines(formatVscodeConfig(port), " ")) + fmt.Fprintf(stderr, " > GoLand: click Add Configuration > Go Remote > localhost:%d\n", port) + fmt.Fprintf(stderr, " > Terminal: dlv connect localhost:%d\n", port) + }, func(port int) error { + // dlv exec --api-version=2 --listen=localhost:2345 --accept-multiclient --headless ./debug.bin + return cmd.Dir(filepath.Dir(file)).Debug().Stderr(stderr).Stdout(stdout).Run("dlv", "exec", + "--api-version=2", + fmt.Sprintf("--listen=localhost:%d", port), + // NOTE: --init is ignored if --headless + // "--init", dlvInitFile, + "--headless", + // "--allow-non-terminal-interactive=true", + tmpBin, "-test.v", "-test.run", fmt.Sprintf("$%s^", req.Item.Name)) + }) + if err != nil { + return err + } + return nil + } + err := debug(projectDir, file, io.MultiWriter(os.Stdout, pw), io.MultiWriter(os.Stderr, pw)) + if err != nil { + session.SendEvents(&TestingItemEvent{ + Event: Event_Output, + Msg: "err: " + err.Error(), + }) + } + }() + + go func() { + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + data := scanner.Bytes() + session.SendEvents(&TestingItemEvent{ + Event: Event_Output, + Msg: string(data), + }) + } + }() + return &DebugResponse{ID: id}, nil + }) + }) + + server.HandleFunc("/debug/pollStatus", func(w http.ResponseWriter, r *http.Request) { + netutil.SetCORSHeaders(w) + netutil.HandleJSON(w, r, func(ctx context.Context, r *http.Request) (interface{}, error) { + var req *DebugPollRequest + err := parseBody(r.Body, &req) + if err != nil { + return nil, err + } + if req.ID == "" { + return nil, netutil.ParamErrorf("requires id") + } + session, err := sessionManager.Get(req.ID) + if err != nil { + return nil, err + } + + events, err := session.PollEvents() + if err != nil { + return nil, err + } + return &DebugPollResponse{ + Events: convTestingEvents(events), + }, nil + }) + }) + server.HandleFunc("/debug/destroy", func(w http.ResponseWriter, r *http.Request) { + netutil.SetCORSHeaders(w) + netutil.HandleJSON(w, r, func(ctx context.Context, r *http.Request) (interface{}, error) { + var req *DebugDestroyRequest + err := parseBody(r.Body, &req) + if err != nil { + return nil, err + } + if req.ID == "" { + return nil, netutil.ParamErrorf("requires id") + } + err = sessionManager.Destroy(req.ID) + if err != nil { + return nil, err + } + return nil, nil + }) + }) +} + +func formatVscodeConfig(port int) string { + return fmt.Sprintf(`{ + "configurations": [ + { + "name": "Debug dlv localhost:%d", + "type": "go", + "request": "attach", + "mode": "remote", + "port": %d, + "host": "127.0.0.1" + } + } +}`, port, port) +} diff --git a/cmd/xgo/test-explorer/index.html b/cmd/xgo/test-explorer/index.html index 84c5541b..ecc3daf1 100644 --- a/cmd/xgo/test-explorer/index.html +++ b/cmd/xgo/test-explorer/index.html @@ -20,6 +20,6 @@ - + \ No newline at end of file diff --git a/cmd/xgo/test-explorer/session.go b/cmd/xgo/test-explorer/run.go similarity index 86% rename from cmd/xgo/test-explorer/session.go rename to cmd/xgo/test-explorer/run.go index 87e5447d..8fc6fe08 100644 --- a/cmd/xgo/test-explorer/session.go +++ b/cmd/xgo/test-explorer/run.go @@ -13,13 +13,13 @@ import ( "regexp" "strings" "sync" - "sync/atomic" "time" "github.com/xhd2015/xgo/support/cmd" "github.com/xhd2015/xgo/support/fileutil" "github.com/xhd2015/xgo/support/goinfo" "github.com/xhd2015/xgo/support/netutil" + "github.com/xhd2015/xgo/support/session" ) type StartSessionRequest struct { @@ -28,6 +28,7 @@ type StartSessionRequest struct { type StartSessionResult struct { ID string `json:"id"` } + type Event string const ( @@ -52,8 +53,11 @@ type PollSessionRequest struct { type PollSessionResult struct { Events []*TestingItemEvent `json:"events"` } +type DestroySessionRequest struct { + ID string `json:"id"` +} -type session struct { +type runSession struct { dir string goCmd string exclude []string @@ -62,7 +66,7 @@ type session struct { item *TestingItem - eventCh chan *TestingItemEvent + session session.Session } func getRelDirs(root *TestingItem, file string) []string { @@ -155,7 +159,7 @@ func resolveTests(fullSubDir string) ([]*TestingItem, error) { return results, nil } -func (c *session) Start() error { +func (c *runSession) Start() error { absDir, err := filepath.Abs(c.dir) if err != nil { return err @@ -370,33 +374,21 @@ func buildEvent(testEvent *TestEvent, absDir string, modPath string, resolveTest } } -func (c *session) Poll() []*TestingItemEvent { - var events []*TestingItemEvent - - timeout := time.After(5 * time.Second) - for { - select { - case event := <-c.eventCh: - events = append(events, event) - case <-timeout: - return events - default: - if len(events) > 0 { - return events - } - time.Sleep(100 * time.Millisecond) - } +func convTestingEvents(events []interface{}) []*TestingItemEvent { + testingEvents := make([]*TestingItemEvent, 0, len(events)) + for _, e := range events { + testingEvents = append(testingEvents, e.(*TestingItemEvent)) } + return testingEvents } -func (c *session) sendEvent(event *TestingItemEvent) { - c.eventCh <- event +func (c *runSession) sendEvent(event *TestingItemEvent) { + c.session.SendEvents(event) } -// TODO: add /session/destroy -func setupSessionHandler(server *http.ServeMux, projectDir string, getTestConfig func() (*TestConfig, error)) { - var nextID int64 = 0 - var sessionMapping sync.Map +// TODO: make FE call /session/destroy +func setupRunHandler(server *http.ServeMux, projectDir string, getTestConfig func() (*TestConfig, error)) { + sessionManager := session.NewSessionManager() server.HandleFunc("/session/start", func(w http.ResponseWriter, r *http.Request) { netutil.SetCORSHeaders(w) @@ -415,22 +407,22 @@ func setupSessionHandler(server *http.ServeMux, projectDir string, getTestConfig return nil, err } - idInt := atomic.AddInt64(&nextID, 1) - // to avoid stale requests from older pages - id := fmt.Sprintf("session_%s_%d", time.Now().Format("2006-01-02_15:04:05"), idInt) + id, ses, err := sessionManager.Start() + if err != nil { + return nil, err + } - sess := &session{ + runSess := &runSession{ dir: projectDir, goCmd: config.GoCmd, exclude: config.Exclude, env: config.CmdEnv(), testFlags: config.Flags, - eventCh: make(chan *TestingItemEvent, 100), + session: ses, item: req.TestingItem, } - sessionMapping.Store(id, sess) - err = sess.Start() + err = runSess.Start() if err != nil { return nil, err } @@ -449,17 +441,38 @@ func setupSessionHandler(server *http.ServeMux, projectDir string, getTestConfig if req.ID == "" { return nil, netutil.ParamErrorf("requires id") } - val, ok := sessionMapping.Load(req.ID) - if !ok { - return nil, netutil.ParamErrorf("session %s does not exist or has been removed", req.ID) + session, err := sessionManager.Get(req.ID) + if err != nil { + return nil, err } - sess := val.(*session) - events := sess.Poll() + events, err := session.PollEvents() + if err != nil { + return nil, err + } // fmt.Printf("poll: %v\n", events) return &PollSessionResult{ - Events: events, + Events: convTestingEvents(events), }, nil }) }) + + server.HandleFunc("/session/destroy", func(w http.ResponseWriter, r *http.Request) { + netutil.SetCORSHeaders(w) + netutil.HandleJSON(w, r, func(ctx context.Context, r *http.Request) (interface{}, error) { + var req *DestroySessionRequest + err := parseBody(r.Body, &req) + if err != nil { + return nil, err + } + if req.ID == "" { + return nil, netutil.ParamErrorf("requires id") + } + err = sessionManager.Destroy(req.ID) + if err != nil { + return nil, err + } + return nil, nil + }) + }) } diff --git a/cmd/xgo/test-explorer/test_explorer.go b/cmd/xgo/test-explorer/test_explorer.go index 60740cb0..66a1a984 100644 --- a/cmd/xgo/test-explorer/test_explorer.go +++ b/cmd/xgo/test-explorer/test_explorer.go @@ -264,7 +264,8 @@ func handle(opts *Options) error { }) }) - setupSessionHandler(server, opts.ProjectDir, getTestConfig) + setupRunHandler(server, opts.ProjectDir, getTestConfig) + setupDebugHandler(server, opts.ProjectDir, getTestConfig) setupOpenHandler(server) return netutil.ServePortHTTP(server, 7070, true, 500*time.Millisecond, func(port int) { diff --git a/cmd/xgo/version.go b/cmd/xgo/version.go index 7abbb2aa..ba561652 100644 --- a/cmd/xgo/version.go +++ b/cmd/xgo/version.go @@ -3,8 +3,8 @@ package main import "fmt" const VERSION = "1.0.37" -const REVISION = "310d0d44809c8f2ad26761138fb8eb3cc4db75c9+1" -const NUMBER = 238 +const REVISION = "62c6c037c1f57c371e227dd1bb8b8e141367f1c6+1" +const NUMBER = 239 func getRevision() string { revSuffix := "" diff --git a/runtime/core/version.go b/runtime/core/version.go index 8093db68..4f249e53 100644 --- a/runtime/core/version.go +++ b/runtime/core/version.go @@ -7,8 +7,8 @@ import ( ) const VERSION = "1.0.37" -const REVISION = "310d0d44809c8f2ad26761138fb8eb3cc4db75c9+1" -const NUMBER = 238 +const REVISION = "62c6c037c1f57c371e227dd1bb8b8e141367f1c6+1" +const NUMBER = 239 // these fields will be filled by compiler const XGO_VERSION = "" diff --git a/support/cmd/cmd.go b/support/cmd/cmd.go index cc5a85d6..e14b7df5 100644 --- a/support/cmd/cmd.go +++ b/support/cmd/cmd.go @@ -63,6 +63,7 @@ func (c *CmdBuilder) Stderr(stderr io.Writer) *CmdBuilder { c.stderr = stderr return c } + func (c *CmdBuilder) Debug() *CmdBuilder { c.debug = true return c @@ -81,6 +82,12 @@ func cmdExec(cmd string, args []string, dir string, pipeStdout bool) (string, er return cmdExecEnv(cmd, args, nil, dir, pipeStdout, nil) } func cmdExecEnv(cmd string, args []string, env []string, dir string, useStdout bool, c *CmdBuilder) (string, error) { + var stderr io.Writer + if c != nil && c.stderr != nil { + stderr = c.stderr + } else { + stderr = os.Stderr + } if c != nil && c.debug { var lines []string if len(env) > 0 { @@ -97,16 +104,12 @@ func cmdExecEnv(cmd string, args []string, env []string, dir string, useStdout b } lines = append(lines, cmdStr) for _, line := range lines { - fmt.Fprintln(os.Stderr, line) + fmt.Fprintln(stderr, line) } } execCmd := exec.Command(cmd, args...) - if c != nil && c.stderr != nil { - execCmd.Stderr = c.stderr - } else { - execCmd.Stderr = os.Stderr - } + execCmd.Stderr = stderr if len(env) > 0 { execCmd.Env = os.Environ() execCmd.Env = append(execCmd.Env, env...) diff --git a/support/session/session.go b/support/session/session.go new file mode 100644 index 00000000..d77c7b60 --- /dev/null +++ b/support/session/session.go @@ -0,0 +1,90 @@ +package session + +import ( + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/xhd2015/xgo/support/netutil" +) + +type SessionManager interface { + // return id and error + Start() (string, Session, error) + Get(id string) (Session, error) + Destroy(id string) error +} + +func NewSessionManager() SessionManager { + return &sessionManager{} +} + +type Session interface { + SendEvents(events ...interface{}) error + PollEvents() ([]interface{}, error) +} + +type sessionImpl struct { + ch chan interface{} +} + +func (c *sessionImpl) SendEvents(events ...interface{}) error { + for _, e := range events { + c.ch <- e + } + return nil +} + +func (c *sessionImpl) PollEvents() ([]interface{}, error) { + events := c.poll(5*time.Second, 100*time.Millisecond) + return events, nil +} + +func (c *sessionImpl) poll(timeout time.Duration, pollInterval time.Duration) []interface{} { + var events []interface{} + + timeoutCh := time.After(timeout) + for { + select { + case event := <-c.ch: + events = append(events, event) + case <-timeoutCh: + return events + default: + if len(events) > 0 { + return events + } + time.Sleep(pollInterval) + } + } +} + +type sessionManager struct { + nextID int64 + mapping sync.Map +} + +func (c *sessionManager) Start() (string, Session, error) { + // to avoid stale requests from older pages + idInt := atomic.AddInt64(&c.nextID, 1) + id := fmt.Sprintf("session_%s_%d", time.Now().Format("2006-01-02_15:04:05"), idInt) + session := &sessionImpl{ + ch: make(chan interface{}, 100), + } + c.mapping.Store(id, session) + return id, session, nil +} +func (c *sessionManager) Get(id string) (Session, error) { + val, ok := c.mapping.Load(id) + if !ok { + return nil, netutil.ParamErrorf("session %s does not exist or has been removed", id) + } + session := val.(*sessionImpl) + return session, nil +} + +func (c *sessionManager) Destroy(id string) error { + c.mapping.Delete(id) + return nil +} diff --git a/support/strutil/indent.go b/support/strutil/indent.go new file mode 100644 index 00000000..e61190eb --- /dev/null +++ b/support/strutil/indent.go @@ -0,0 +1,12 @@ +package strutil + +import "strings" + +func IndentLines(content string, prefix string) string { + lines := strings.Split(content, "\n") + n := len(lines) + for i := 0; i < n; i++ { + lines[i] = prefix + lines[i] + } + return strings.Join(lines, "\n") +}