From f720288c10c6de5794162053ba565ce090d4f9a3 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 18 Aug 2017 14:21:01 -0500 Subject: windows support for plugin ipc (#7251) * windows support for plugin ipc * unix test fix --- plugin/rpcplugin/ipc_test.go | 2 +- plugin/rpcplugin/main_test.go | 7 +- plugin/rpcplugin/process_test.go | 2 +- plugin/rpcplugin/process_windows.go | 641 +++++++++++++++++++++++++++++++++++- plugin/rpcplugin/supervisor_test.go | 12 +- 5 files changed, 649 insertions(+), 15 deletions(-) (limited to 'plugin') diff --git a/plugin/rpcplugin/ipc_test.go b/plugin/rpcplugin/ipc_test.go index bf4df3017..25cd197be 100644 --- a/plugin/rpcplugin/ipc_test.go +++ b/plugin/rpcplugin/ipc_test.go @@ -16,7 +16,7 @@ func TestIPC(t *testing.T) { require.NoError(t, err) defer os.RemoveAll(dir) - pingpong := filepath.Join(dir, "pingpong") + pingpong := filepath.Join(dir, "pingpong.exe") compileGo(t, ` package main diff --git a/plugin/rpcplugin/main_test.go b/plugin/rpcplugin/main_test.go index b54364bad..a796516e7 100644 --- a/plugin/rpcplugin/main_test.go +++ b/plugin/rpcplugin/main_test.go @@ -18,7 +18,7 @@ func TestMain(t *testing.T) { require.NoError(t, err) defer os.RemoveAll(dir) - plugin := filepath.Join(dir, "plugin") + plugin := filepath.Join(dir, "plugin.exe") compileGo(t, ` package main @@ -42,13 +42,16 @@ func TestMain(t *testing.T) { } `, plugin) - p, ipc, err := NewProcess(context.Background(), plugin) + ctx, cancel := context.WithCancel(context.Background()) + p, ipc, err := NewProcess(ctx, plugin) require.NoError(t, err) defer p.Wait() muxer := NewMuxer(ipc, false) defer muxer.Close() + defer cancel() + var api plugintest.API hooks, err := ConnectMain(muxer) diff --git a/plugin/rpcplugin/process_test.go b/plugin/rpcplugin/process_test.go index b7984ad0a..96d89536c 100644 --- a/plugin/rpcplugin/process_test.go +++ b/plugin/rpcplugin/process_test.go @@ -29,7 +29,7 @@ func TestProcess(t *testing.T) { require.NoError(t, err) defer os.RemoveAll(dir) - ping := filepath.Join(dir, "ping") + ping := filepath.Join(dir, "ping.exe") compileGo(t, ` package main diff --git a/plugin/rpcplugin/process_windows.go b/plugin/rpcplugin/process_windows.go index 7be03cacd..069f147c1 100644 --- a/plugin/rpcplugin/process_windows.go +++ b/plugin/rpcplugin/process_windows.go @@ -2,16 +2,647 @@ package rpcplugin import ( "context" + "errors" "fmt" "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "unicode/utf16" + "unsafe" + + pkgerrors "github.com/pkg/errors" ) +type process struct { + command *cmd +} + func newProcess(ctx context.Context, path string) (Process, io.ReadWriteCloser, error) { - // TODO - return nil, nil, fmt.Errorf("not yet supported") + ipc, childFiles, err := NewIPC() + if err != nil { + return nil, nil, err + } + defer childFiles[0].Close() + defer childFiles[1].Close() + + cmd := commandContext(ctx, path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.ExtraFiles = childFiles + cmd.Env = append(os.Environ(), + fmt.Sprintf("MM_IPC_FD0=%v", childFiles[0].Fd()), + fmt.Sprintf("MM_IPC_FD1=%v", childFiles[1].Fd()), + ) + err = cmd.Start() + if err != nil { + ipc.Close() + return nil, nil, err + } + + return &process{ + command: cmd, + }, ipc, nil +} + +func (p *process) Wait() error { + return p.command.Wait() +} + +func inheritedProcessIPC() (io.ReadWriteCloser, error) { + fd0, err := strconv.ParseUint(os.Getenv("MM_IPC_FD0"), 0, 64) + if err != nil { + return nil, pkgerrors.Wrapf(err, "unable to get ipc file descriptor 0") + } + fd1, err := strconv.ParseUint(os.Getenv("MM_IPC_FD1"), 0, 64) + if err != nil { + return nil, pkgerrors.Wrapf(err, "unable to get ipc file descriptor 1") + } + return InheritedIPC(uintptr(fd0), uintptr(fd1)) +} + +// XXX: EVERYTHING BELOW THIS IS COPIED / PASTED STANDARD LIBRARY CODE! +// IT CAN BE DELETED IF / WHEN THIS ISSUE IS RESOLVED: https://github.com/golang/go/issues/21085 + +// Just about all of os/exec/exec.go is copied / pasted below, altered to use our modified startProcess functions even +// further below. + +type cmd struct { + // Path is the path of the command to run. + // + // This is the only field that must be set to a non-zero + // value. If Path is relative, it is evaluated relative + // to Dir. + Path string + + // Args holds command line arguments, including the command as Args[0]. + // If the Args field is empty or nil, Run uses {Path}. + // + // In typical use, both Path and Args are set by calling Command. + Args []string + + // Env specifies the environment of the process. + // If Env is nil, Run uses the current process's environment. + Env []string + + // Dir specifies the working directory of the command. + // If Dir is the empty string, Run runs the command in the + // calling process's current directory. + Dir string + + // Stdin specifies the process's standard input. + // If Stdin is nil, the process reads from the null device (os.DevNull). + // If Stdin is an *os.File, the process's standard input is connected + // directly to that file. + // Otherwise, during the execution of the command a separate + // goroutine reads from Stdin and delivers that data to the command + // over a pipe. In this case, Wait does not complete until the goroutine + // stops copying, either because it has reached the end of Stdin + // (EOF or a read error) or because writing to the pipe returned an error. + Stdin io.Reader + + // Stdout and Stderr specify the process's standard output and error. + // + // If either is nil, Run connects the corresponding file descriptor + // to the null device (os.DevNull). + // + // If Stdout and Stderr are the same writer, at most one + // goroutine at a time will call Write. + Stdout io.Writer + Stderr io.Writer + + // ExtraFiles specifies additional open files to be inherited by the + // new process. It does not include standard input, standard output, or + // standard error. If non-nil, entry i becomes file descriptor 3+i. + // + // BUG(rsc): On OS X 10.6, child processes may sometimes inherit unwanted fds. + // https://golang.org/issue/2603 + ExtraFiles []*os.File + + // SysProcAttr holds optional, operating system-specific attributes. + // Run passes it to os.StartProcess as the os.ProcAttr's Sys field. + SysProcAttr *syscall.SysProcAttr + + // Process is the underlying process, once started. + Process *os.Process + + // ProcessState contains information about an exited process, + // available after a call to Wait or Run. + ProcessState *os.ProcessState + + ctx context.Context // nil means none + lookPathErr error // LookPath error, if any. + finished bool // when Wait was called + childFiles []*os.File + closeAfterStart []io.Closer + closeAfterWait []io.Closer + goroutine []func() error + errch chan error // one send per goroutine + waitDone chan struct{} +} + +func command(name string, arg ...string) *cmd { + cmd := &cmd{ + Path: name, + Args: append([]string{name}, arg...), + } + if filepath.Base(name) == name { + if lp, err := exec.LookPath(name); err != nil { + cmd.lookPathErr = err + } else { + cmd.Path = lp + } + } + return cmd } -func inheritedProcessIPC() (*IPC, error) { - // TODO - return nil, fmt.Errorf("not yet supported") +func commandContext(ctx context.Context, name string, arg ...string) *cmd { + if ctx == nil { + panic("nil Context") + } + cmd := command(name, arg...) + cmd.ctx = ctx + return cmd +} + +func interfaceEqual(a, b interface{}) bool { + defer func() { + recover() + }() + return a == b +} + +func (c *cmd) envv() []string { + if c.Env != nil { + return c.Env + } + return os.Environ() +} + +func (c *cmd) argv() []string { + if len(c.Args) > 0 { + return c.Args + } + return []string{c.Path} +} + +var skipStdinCopyError func(error) bool + +func (c *cmd) stdin() (f *os.File, err error) { + if c.Stdin == nil { + f, err = os.Open(os.DevNull) + if err != nil { + return + } + c.closeAfterStart = append(c.closeAfterStart, f) + return + } + + if f, ok := c.Stdin.(*os.File); ok { + return f, nil + } + + pr, pw, err := os.Pipe() + if err != nil { + return + } + + c.closeAfterStart = append(c.closeAfterStart, pr) + c.closeAfterWait = append(c.closeAfterWait, pw) + c.goroutine = append(c.goroutine, func() error { + _, err := io.Copy(pw, c.Stdin) + if skip := skipStdinCopyError; skip != nil && skip(err) { + err = nil + } + if err1 := pw.Close(); err == nil { + err = err1 + } + return err + }) + return pr, nil +} + +func (c *cmd) stdout() (f *os.File, err error) { + return c.writerDescriptor(c.Stdout) +} + +func (c *cmd) stderr() (f *os.File, err error) { + if c.Stderr != nil && interfaceEqual(c.Stderr, c.Stdout) { + return c.childFiles[1], nil + } + return c.writerDescriptor(c.Stderr) +} + +func (c *cmd) writerDescriptor(w io.Writer) (f *os.File, err error) { + if w == nil { + f, err = os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return + } + c.closeAfterStart = append(c.closeAfterStart, f) + return + } + + if f, ok := w.(*os.File); ok { + return f, nil + } + + pr, pw, err := os.Pipe() + if err != nil { + return + } + + c.closeAfterStart = append(c.closeAfterStart, pw) + c.closeAfterWait = append(c.closeAfterWait, pr) + c.goroutine = append(c.goroutine, func() error { + _, err := io.Copy(w, pr) + pr.Close() // in case io.Copy stopped due to write error + return err + }) + return pw, nil +} + +func (c *cmd) closeDescriptors(closers []io.Closer) { + for _, fd := range closers { + fd.Close() + } +} + +func lookExtensions(path, dir string) (string, error) { + if filepath.Base(path) == path { + path = filepath.Join(".", path) + } + if dir == "" { + return exec.LookPath(path) + } + if filepath.VolumeName(path) != "" { + return exec.LookPath(path) + } + if len(path) > 1 && os.IsPathSeparator(path[0]) { + return exec.LookPath(path) + } + dirandpath := filepath.Join(dir, path) + // We assume that LookPath will only add file extension. + lp, err := exec.LookPath(dirandpath) + if err != nil { + return "", err + } + ext := strings.TrimPrefix(lp, dirandpath) + return path + ext, nil +} + +// Copied from os/exec/exec.go, altered to use osStartProcess (defined below). +func (c *cmd) Start() error { + if c.lookPathErr != nil { + c.closeDescriptors(c.closeAfterStart) + c.closeDescriptors(c.closeAfterWait) + return c.lookPathErr + } + if runtime.GOOS == "windows" { + lp, err := lookExtensions(c.Path, c.Dir) + if err != nil { + c.closeDescriptors(c.closeAfterStart) + c.closeDescriptors(c.closeAfterWait) + return err + } + c.Path = lp + } + if c.Process != nil { + return errors.New("exec: already started") + } + if c.ctx != nil { + select { + case <-c.ctx.Done(): + c.closeDescriptors(c.closeAfterStart) + c.closeDescriptors(c.closeAfterWait) + return c.ctx.Err() + default: + } + } + + type F func(*cmd) (*os.File, error) + for _, setupFd := range []F{(*cmd).stdin, (*cmd).stdout, (*cmd).stderr} { + fd, err := setupFd(c) + if err != nil { + c.closeDescriptors(c.closeAfterStart) + c.closeDescriptors(c.closeAfterWait) + return err + } + c.childFiles = append(c.childFiles, fd) + } + c.childFiles = append(c.childFiles, c.ExtraFiles...) + + var err error + c.Process, err = osStartProcess(c.Path, c.argv(), &os.ProcAttr{ + Dir: c.Dir, + Files: c.childFiles, + Env: c.envv(), + Sys: c.SysProcAttr, + }) + if err != nil { + c.closeDescriptors(c.closeAfterStart) + c.closeDescriptors(c.closeAfterWait) + return err + } + + c.closeDescriptors(c.closeAfterStart) + + c.errch = make(chan error, len(c.goroutine)) + for _, fn := range c.goroutine { + go func(fn func() error) { + c.errch <- fn() + }(fn) + } + + if c.ctx != nil { + c.waitDone = make(chan struct{}) + go func() { + select { + case <-c.ctx.Done(): + c.Process.Kill() + case <-c.waitDone: + } + }() + } + + return nil +} + +func (c *cmd) Wait() error { + if c.Process == nil { + return errors.New("exec: not started") + } + if c.finished { + return errors.New("exec: Wait was already called") + } + c.finished = true + + state, err := c.Process.Wait() + if c.waitDone != nil { + close(c.waitDone) + } + c.ProcessState = state + + var copyError error + for range c.goroutine { + if err := <-c.errch; err != nil && copyError == nil { + copyError = err + } + } + + c.closeDescriptors(c.closeAfterWait) + + if err != nil { + return err + } else if !state.Success() { + return &exec.ExitError{ProcessState: state} + } + + return copyError +} + +// Copied from os/exec_posix.go, altered to use syscallStartProcess (defined below). +func osStartProcess(name string, argv []string, attr *os.ProcAttr) (p *os.Process, err error) { + // If there is no SysProcAttr (ie. no Chroot or changed + // UID/GID), double-check existence of the directory we want + // to chdir into. We can make the error clearer this way. + if attr != nil && attr.Sys == nil && attr.Dir != "" { + if _, err := os.Stat(attr.Dir); err != nil { + pe := err.(*os.PathError) + pe.Op = "chdir" + return nil, pe + } + } + + sysattr := &syscall.ProcAttr{ + Dir: attr.Dir, + Env: attr.Env, + Sys: attr.Sys, + } + if sysattr.Env == nil { + sysattr.Env = os.Environ() + } + for _, f := range attr.Files { + sysattr.Files = append(sysattr.Files, f.Fd()) + } + + pid, _, e := syscallStartProcess(name, argv, sysattr) + if e != nil { + return nil, &os.PathError{Op: "fork/exec", Path: name, Err: e} + } + return os.FindProcess(pid) +} + +// Everything from this point on is copied from syscall/exec_windows.go + +func makeCmdLine(args []string) string { + var s string + for _, v := range args { + if s != "" { + s += " " + } + s += syscall.EscapeArg(v) + } + return s +} + +func createEnvBlock(envv []string) *uint16 { + if len(envv) == 0 { + return &utf16.Encode([]rune("\x00\x00"))[0] + } + length := 0 + for _, s := range envv { + length += len(s) + 1 + } + length += 1 + + b := make([]byte, length) + i := 0 + for _, s := range envv { + l := len(s) + copy(b[i:i+l], []byte(s)) + copy(b[i+l:i+l+1], []byte{0}) + i = i + l + 1 + } + copy(b[i:i+1], []byte{0}) + + return &utf16.Encode([]rune(string(b)))[0] +} + +func isSlash(c uint8) bool { + return c == '\\' || c == '/' +} + +func normalizeDir(dir string) (name string, err error) { + ndir, err := syscall.FullPath(dir) + if err != nil { + return "", err + } + if len(ndir) > 2 && isSlash(ndir[0]) && isSlash(ndir[1]) { + // dir cannot have \\server\share\path form + return "", syscall.EINVAL + } + return ndir, nil +} + +func volToUpper(ch int) int { + if 'a' <= ch && ch <= 'z' { + ch += 'A' - 'a' + } + return ch +} + +func joinExeDirAndFName(dir, p string) (name string, err error) { + if len(p) == 0 { + return "", syscall.EINVAL + } + if len(p) > 2 && isSlash(p[0]) && isSlash(p[1]) { + // \\server\share\path form + return p, nil + } + if len(p) > 1 && p[1] == ':' { + // has drive letter + if len(p) == 2 { + return "", syscall.EINVAL + } + if isSlash(p[2]) { + return p, nil + } else { + d, err := normalizeDir(dir) + if err != nil { + return "", err + } + if volToUpper(int(p[0])) == volToUpper(int(d[0])) { + return syscall.FullPath(d + "\\" + p[2:]) + } else { + return syscall.FullPath(p) + } + } + } else { + // no drive letter + d, err := normalizeDir(dir) + if err != nil { + return "", err + } + if isSlash(p[0]) { + return syscall.FullPath(d[:2] + p) + } else { + return syscall.FullPath(d + "\\" + p) + } + } +} + +var zeroProcAttr syscall.ProcAttr +var zeroSysProcAttr syscall.SysProcAttr + +// Has minor changes to support file inheritance. +func syscallStartProcess(argv0 string, argv []string, attr *syscall.ProcAttr) (pid int, handle uintptr, err error) { + if len(argv0) == 0 { + return 0, 0, syscall.EWINDOWS + } + if attr == nil { + attr = &zeroProcAttr + } + sys := attr.Sys + if sys == nil { + sys = &zeroSysProcAttr + } + + if len(attr.Files) < 3 { + return 0, 0, syscall.EINVAL + } + + if len(attr.Dir) != 0 { + // StartProcess assumes that argv0 is relative to attr.Dir, + // because it implies Chdir(attr.Dir) before executing argv0. + // Windows CreateProcess assumes the opposite: it looks for + // argv0 relative to the current directory, and, only once the new + // process is started, it does Chdir(attr.Dir). We are adjusting + // for that difference here by making argv0 absolute. + var err error + argv0, err = joinExeDirAndFName(attr.Dir, argv0) + if err != nil { + return 0, 0, err + } + } + argv0p, err := syscall.UTF16PtrFromString(argv0) + if err != nil { + return 0, 0, err + } + + var cmdline string + // Windows CreateProcess takes the command line as a single string: + // use attr.CmdLine if set, else build the command line by escaping + // and joining each argument with spaces + if sys.CmdLine != "" { + cmdline = sys.CmdLine + } else { + cmdline = makeCmdLine(argv) + } + + var argvp *uint16 + if len(cmdline) != 0 { + argvp, err = syscall.UTF16PtrFromString(cmdline) + if err != nil { + return 0, 0, err + } + } + + var dirp *uint16 + if len(attr.Dir) != 0 { + dirp, err = syscall.UTF16PtrFromString(attr.Dir) + if err != nil { + return 0, 0, err + } + } + + // Acquire the fork lock so that no other threads + // create new fds that are not yet close-on-exec + // before we fork. + syscall.ForkLock.Lock() + defer syscall.ForkLock.Unlock() + + p, _ := syscall.GetCurrentProcess() + fd := make([]syscall.Handle, len(attr.Files)) + for i := range attr.Files { + if attr.Files[i] <= 0 { + continue + } + if i < 3 { + err := syscall.DuplicateHandle(p, syscall.Handle(attr.Files[i]), p, &fd[i], 0, true, syscall.DUPLICATE_SAME_ACCESS) + if err != nil { + return 0, 0, err + } + defer syscall.CloseHandle(syscall.Handle(fd[i])) + } else { + // This is the modification that allows files to be inherited. + syscall.SetHandleInformation(syscall.Handle(attr.Files[i]), syscall.HANDLE_FLAG_INHERIT, 1) + defer syscall.SetHandleInformation(syscall.Handle(attr.Files[i]), syscall.HANDLE_FLAG_INHERIT, 0) + } + } + si := new(syscall.StartupInfo) + si.Cb = uint32(unsafe.Sizeof(*si)) + si.Flags = syscall.STARTF_USESTDHANDLES + if sys.HideWindow { + si.Flags |= syscall.STARTF_USESHOWWINDOW + si.ShowWindow = syscall.SW_HIDE + } + si.StdInput = fd[0] + si.StdOutput = fd[1] + si.StdErr = fd[2] + + pi := new(syscall.ProcessInformation) + + flags := sys.CreationFlags | syscall.CREATE_UNICODE_ENVIRONMENT + err = syscall.CreateProcess(argv0p, argvp, nil, nil, true, flags, createEnvBlock(attr.Env), dirp, si, pi) + if err != nil { + return 0, 0, err + } + defer syscall.CloseHandle(syscall.Handle(pi.Thread)) + + return int(pi.ProcessId), uintptr(pi.Process), nil } diff --git a/plugin/rpcplugin/supervisor_test.go b/plugin/rpcplugin/supervisor_test.go index 1d046bf82..438ebe02a 100644 --- a/plugin/rpcplugin/supervisor_test.go +++ b/plugin/rpcplugin/supervisor_test.go @@ -18,7 +18,7 @@ func TestSupervisor(t *testing.T) { require.NoError(t, err) defer os.RemoveAll(dir) - backend := filepath.Join(dir, "backend") + backend := filepath.Join(dir, "backend.exe") compileGo(t, ` package main @@ -42,7 +42,7 @@ func TestSupervisor(t *testing.T) { } `, backend) - ioutil.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"id": "foo", "backend": {"executable": "backend"}}`), 0600) + ioutil.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"id": "foo", "backend": {"executable": "backend.exe"}}`), 0600) bundle := plugin.BundleInfoForPath(dir) supervisor, err := SupervisorProvider(bundle) @@ -58,7 +58,7 @@ func TestSupervisor_StartTimeout(t *testing.T) { require.NoError(t, err) defer os.RemoveAll(dir) - backend := filepath.Join(dir, "backend") + backend := filepath.Join(dir, "backend.exe") compileGo(t, ` package main @@ -68,7 +68,7 @@ func TestSupervisor_StartTimeout(t *testing.T) { } `, backend) - ioutil.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"id": "foo", "backend": {"executable": "backend"}}`), 0600) + ioutil.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"id": "foo", "backend": {"executable": "backend.exe"}}`), 0600) bundle := plugin.BundleInfoForPath(dir) supervisor, err := SupervisorProvider(bundle) @@ -82,7 +82,7 @@ func TestSupervisor_PluginCrash(t *testing.T) { require.NoError(t, err) defer os.RemoveAll(dir) - backend := filepath.Join(dir, "backend") + backend := filepath.Join(dir, "backend.exe") compileGo(t, ` package main @@ -109,7 +109,7 @@ func TestSupervisor_PluginCrash(t *testing.T) { } `, backend) - ioutil.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"id": "foo", "backend": {"executable": "backend"}}`), 0600) + ioutil.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"id": "foo", "backend": {"executable": "backend.exe"}}`), 0600) bundle := plugin.BundleInfoForPath(dir) supervisor, err := SupervisorProvider(bundle) -- cgit v1.2.3-1-g7c22