├── .github └── workflows │ └── release.yaml ├── .gitignore ├── LICENSE ├── README.md ├── executor.go ├── exit_status.go ├── exit_status_plan9.go ├── logger.go ├── main.go ├── process.go ├── protocol.go └── util.go /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: release 2 | name: Build 3 | jobs: 4 | release-linux-386: 5 | name: release linux/386 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | - name: compile and release 10 | uses: ngs/go-release.action@v1.0.2 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | GOARCH: "386" 14 | GOOS: linux 15 | release-linux-amd64: 16 | name: release linux/amd64 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@master 20 | - name: compile and release 21 | uses: ngs/go-release.action@v1.0.2 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | GOARCH: amd64 25 | GOOS: linux 26 | release-darwin-386: 27 | name: release darwin/386 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@master 31 | - name: compile and release 32 | uses: ngs/go-release.action@v1.0.2 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | GOARCH: "386" 36 | GOOS: darwin 37 | release-darwin-amd64: 38 | name: release darwin/amd64 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@master 42 | - name: compile and release 43 | uses: ngs/go-release.action@v1.0.2 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | GOARCH: amd64 47 | GOOS: darwin 48 | release-windows-386: 49 | name: release windows/386 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@master 53 | - name: compile and release 54 | uses: ngs/go-release.action@v1.0.2 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | GOARCH: "386" 58 | GOOS: windows 59 | release-windows-amd64: 60 | name: release windows/amd64 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@master 64 | - name: compile and release 65 | uses: ngs/go-release.action@v1.0.2 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | GOARCH: amd64 69 | GOOS: windows 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /archives 2 | /odu 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright for portions of project odu are held by Alexei Sholik , 2014 as part of project goon. All other copyright for project odu are held by Akash Hiremath , 2020. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Odu 2 | ==== 3 | 4 | **Moved to [ExCmd](https://github.com/akash-akya/ex_cmd)** 5 | 6 | Odu is a middleware program which helps with talking to external programs from Elixir or Erlang. 7 | 8 | Port implementation in beam has several limitation when it comes to spawning external command and communicating with it. Limitations such as no back-pressure to external command, ability wait to output after closing stdin (when port is closed, beam closes both stdin and stdout), possibility of zombie proccess. Odu together [ExCmd](https://github.com/akash-akya/ex_cmd) tries to fix these issues. 9 | 10 | Odu is based on [goon](https://github.com/alco/goon) by [Alexei Sholik](https://github.com/alco). 11 | 12 | ## Usage 13 | 14 | Put odu somewhere in your `PATH` (or into the directory that will become the current 15 | working directory of your application) and use [ExCmd](https://github.com/akash-akya/ex_cmd) Elixir library 16 | 17 | ## Building from source 18 | 19 | ```sh 20 | $ go build 21 | ``` 22 | 23 | ## License 24 | 25 | This software is licensed under [the MIT license](LICENSE). 26 | -------------------------------------------------------------------------------- /executor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "time" 7 | ) 8 | 9 | func execute(workdir string, args []string) error { 10 | done := make(chan struct{}) 11 | 12 | input := make(chan Packet, 1) 13 | outputDemand := make(chan Packet) 14 | inputDemand := make(chan Packet) 15 | 16 | proc := exec.Command(args[0], args[1:]...) 17 | proc.Dir = workdir 18 | proc.Env = append(os.Environ(), readEnvFromStdin()...) 19 | 20 | logger.Printf("Command path: %v\n", proc.Path) 21 | 22 | output := startCommandPipeline(proc, input, inputDemand, outputDemand) 23 | go dispatchStdin(input, outputDemand, done) 24 | go collectStdout(proc.Process.Pid, output, inputDemand, done) 25 | 26 | // wait for pipline to exit 27 | <-done 28 | 29 | err := safeExit(proc) 30 | if e, ok := err.(*exec.Error); ok { 31 | // This shouldn't really happen in practice because we check for 32 | // program existence in Elixir, before launching odu 33 | logger.Printf("Command exited with error: %v\n", e) 34 | os.Exit(3) 35 | } 36 | // TODO: return Stderr and exit stauts to beam process 37 | logger.Printf("Command exited: %#v\n", err) 38 | return err 39 | } 40 | 41 | func dispatchStdin(input chan<- Packet, outputDemand chan<- Packet, done chan struct{}) { 42 | // closeChan := closeInputHandler(input) 43 | var dispatch = func(packet Packet) { 44 | switch packet.tag { 45 | case SendOutput: 46 | outputDemand <- packet 47 | default: 48 | input <- packet 49 | } 50 | } 51 | 52 | defer func() { 53 | close(input) 54 | close(outputDemand) 55 | }() 56 | 57 | stdinReader(dispatch, done) 58 | } 59 | 60 | func collectStdout(pid int, output <-chan Packet, inputDemand <-chan Packet, done chan struct{}) { 61 | defer func() { 62 | close(done) 63 | }() 64 | 65 | merged := func() (Packet, bool) { 66 | select { 67 | case v, ok := <-inputDemand: 68 | return v, ok 69 | case v, ok := <-output: 70 | return v, ok 71 | } 72 | } 73 | 74 | stdoutWriter(pid, merged, done) 75 | } 76 | 77 | func safeExit(proc *exec.Cmd) error { 78 | done := make(chan error, 1) 79 | go func() { 80 | done <- proc.Wait() 81 | }() 82 | select { 83 | case <-time.After(3 * time.Second): 84 | if err := proc.Process.Kill(); err != nil { 85 | logger.Fatal("failed to kill process: ", err) 86 | } 87 | logger.Println("process killed as timeout reached") 88 | return nil 89 | case err := <-done: 90 | return err 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /exit_status.go: -------------------------------------------------------------------------------- 1 | // +build !plan9 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "os/exec" 8 | "syscall" 9 | ) 10 | 11 | func getExitStatus(err error) int { 12 | switch e := err.(type) { 13 | case *exec.ExitError: 14 | switch s := e.ProcessState.Sys().(type) { 15 | case syscall.WaitStatus: 16 | return s.ExitStatus() 17 | } 18 | } 19 | return 1 20 | } 21 | 22 | func makeSignal(sig byte) os.Signal { 23 | return syscall.Signal(int(sig)) 24 | } 25 | -------------------------------------------------------------------------------- /exit_status_plan9.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "syscall" 7 | ) 8 | 9 | func getExitStatus(err error) int { 10 | switch e := err.(type) { 11 | case *exec.ExitError: 12 | switch s := e.ProcessState.Sys().(type) { 13 | case syscall.Waitmsg: 14 | return s.ExitStatus() 15 | } 16 | } 17 | return 1 18 | } 19 | 20 | func makeSignal(sig byte) os.Signal { 21 | switch sig { 22 | case 128: 23 | return syscall.Note("interrupt") 24 | case 129: 25 | return syscall.Note("sys: kill") 26 | case 15: 27 | return syscall.Note("kill") 28 | default: 29 | return syscall.Note("") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os" 7 | ) 8 | 9 | var logger *log.Logger 10 | 11 | func initLogger(flag string) { 12 | var file io.Writer 13 | switch flag { 14 | case "": 15 | file = NullReadWriteCloser{ 16 | Signal: make(chan struct{}, 1), 17 | } 18 | case "|1": 19 | file = os.Stdout 20 | case "|2": 21 | file = os.Stderr 22 | default: 23 | var err error 24 | file, err = os.OpenFile(flag, os.O_CREATE|os.O_WRONLY, 0666) 25 | fatalIf(err) 26 | } 27 | logger = log.New(file, "[odu]: ", log.Lmicroseconds) 28 | } 29 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | // Version of the odu 10 | const Version = "0.1.0" 11 | 12 | // Supported protocol version 13 | const ProtocolVersion = "1.0" 14 | 15 | const usage = "Usage: odu [options] -- [...]" 16 | 17 | var cdFlag = flag.String("cd", ".", "working directory for the spawned process") 18 | var logFlag = flag.String("log", "", "enable logging") 19 | var protocolVersionFlag = flag.String("protocol_version", "", "protocol version") 20 | var versionFlag = flag.Bool("v", false, "print version and exit") 21 | 22 | func main() { 23 | flag.Parse() 24 | 25 | initLogger(*logFlag) 26 | 27 | if *versionFlag { 28 | fmt.Printf("odu version: %s\nprotocol_version: %s", Version, ProtocolVersion) 29 | os.Exit(0) 30 | } 31 | 32 | args := flag.Args() 33 | validateArgs(args) 34 | 35 | err := execute(*cdFlag, args) 36 | if err != nil { 37 | os.Exit(getExitStatus(err)) 38 | } 39 | } 40 | 41 | func validateArgs(args []string) { 42 | if len(args) < 1 { 43 | dieUsage("Not enough arguments.") 44 | } 45 | 46 | if *protocolVersionFlag != "1.0" { 47 | dieUsage(fmt.Sprintf("Invalid version specified: %v Supported version: %v", *protocolVersionFlag, ProtocolVersion)) 48 | } 49 | 50 | logger.Printf("dir:%v, log:%v, protocol_version:%v, args:%v\n", *cdFlag, *logFlag, *protocolVersionFlag, args) 51 | } 52 | 53 | func notFifo(path string) bool { 54 | info, err := os.Stat(path) 55 | return os.IsNotExist(err) || info.Mode()&os.ModeNamedPipe == 0 56 | } 57 | -------------------------------------------------------------------------------- /process.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | func startCommandPipeline(proc *exec.Cmd, input <-chan Packet, inputDemand chan<- Packet, outputDemand <-chan Packet) <-chan Packet { 10 | cmdInput, err := proc.StdinPipe() 11 | fatalIf(err) 12 | 13 | cmdOutput, err := proc.StdoutPipe() 14 | fatalIf(err) 15 | 16 | cmdError, err := proc.StderrPipe() 17 | fatalIf(err) 18 | 19 | execErr := proc.Start() 20 | fatalIf(execErr) 21 | 22 | go writeToCommandStdin(cmdInput, input, inputDemand) 23 | 24 | go printStderr(cmdError) 25 | 26 | output := make(chan Packet) 27 | go readCommandStdout(cmdOutput, outputDemand, output) 28 | 29 | return output 30 | } 31 | 32 | func writeToCommandStdin(cmdInput io.WriteCloser, input <-chan Packet, inputDemand chan<- Packet) { 33 | var packet Packet 34 | var ok bool 35 | 36 | defer func() { 37 | cmdInput.Close() 38 | // close(inputDemand) 39 | }() 40 | 41 | for { 42 | inputDemand <- Packet{SendInput, make([]byte, 0)} 43 | 44 | select { 45 | case packet, ok = <-input: 46 | if !ok { 47 | return 48 | } 49 | } 50 | 51 | switch packet.tag { 52 | case CloseInput: 53 | return 54 | 55 | case Input: 56 | // blocking 57 | _, writeErr := cmdInput.Write(packet.data) 58 | if writeErr != nil { 59 | switch writeErr.(type) { 60 | // ignore broken pipe or closed pipe errors 61 | case *os.PathError: 62 | return 63 | default: 64 | fatal(writeErr) 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | func readCommandStdout(cmdOutput io.ReadCloser, outputDemand <-chan Packet, output chan<- Packet) { 72 | var buf [BufferSize]byte 73 | 74 | defer func() { 75 | output <- Packet{OutputEOF, make([]byte, 0)} 76 | cmdOutput.Close() 77 | close(output) 78 | }() 79 | 80 | for { 81 | select { 82 | case _, ok := <-outputDemand: 83 | if !ok { 84 | return 85 | } 86 | } 87 | 88 | // blocking 89 | bytesRead, readErr := cmdOutput.Read(buf[:]) 90 | if bytesRead > 0 { 91 | output <- Packet{Output, buf[:bytesRead]} 92 | } else if readErr == io.EOF || bytesRead == 0 { 93 | return 94 | } else { 95 | fatal(readErr) 96 | } 97 | } 98 | } 99 | 100 | func printStderr(cmdError io.ReadCloser) { 101 | var buf [BufferSize]byte 102 | 103 | defer func() { 104 | cmdError.Close() 105 | }() 106 | 107 | for { 108 | bytesRead, readErr := cmdError.Read(buf[:]) 109 | if bytesRead > 0 { 110 | logger.Printf(string(buf[:bytesRead])) 111 | } else if readErr == io.EOF || bytesRead == 0 { 112 | return 113 | } else { 114 | fatal(readErr) 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /protocol.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/binary" 5 | "errors" 6 | "io" 7 | "os" 8 | ) 9 | 10 | const SendInput = 1 11 | const SendOutput = 2 12 | const Output = 3 13 | const Input = 4 14 | const CloseInput = 5 15 | const OutputEOF = 6 16 | const CommandEnv = 7 17 | const Pid = 8 18 | const StartError = 9 19 | 20 | // This size is *NOT* related to pipe buffer size 21 | // 4 bytes for payload length + 1 byte for tag 22 | const BufferSize = (1 << 16) - 5 23 | 24 | type Packet struct { 25 | tag uint8 26 | data []byte 27 | } 28 | 29 | type InputDispatcher func(Packet) 30 | 31 | func stdinReader(dispatch InputDispatcher, done <-chan struct{}) { 32 | for { 33 | select { 34 | case <-done: 35 | return 36 | default: 37 | } 38 | 39 | packet, readErr := readPacket() 40 | if readErr == io.EOF { 41 | return 42 | } 43 | fatalIf(readErr) 44 | 45 | dispatch(packet) 46 | } 47 | } 48 | 49 | type OutPacket func() (Packet, bool) 50 | 51 | func stdoutWriter(pid int, fn OutPacket, done <-chan struct{}) { 52 | var ok bool 53 | var packet Packet 54 | 55 | var buf [4]byte 56 | 57 | // we first write pid before writing anything 58 | writeUint32Be(buf[:], uint32(pid)) 59 | writePacket(Pid, buf[:]) 60 | 61 | for { 62 | packet, ok = fn() 63 | if !ok { 64 | return 65 | } 66 | 67 | if len(packet.data) > BufferSize { 68 | fatal("Invalid payloadLen") 69 | } 70 | 71 | writePacket(packet.tag, packet.data) 72 | } 73 | } 74 | 75 | func writeStartError(reason string) { 76 | writePacket(StartError, []byte(reason)) 77 | } 78 | 79 | func readEnvFromStdin() []string { 80 | // first packet must be env 81 | packet, err := readPacket() 82 | if err != nil { 83 | fatal(err) 84 | } 85 | 86 | if packet.tag != CommandEnv { 87 | fatal("First packet must be command Env") 88 | } 89 | 90 | var env []string 91 | var length int 92 | data := packet.data 93 | 94 | for i := 0; i < len(data); { 95 | length = int(binary.BigEndian.Uint16(data[i : i+2])) 96 | i += 2 97 | 98 | entry := string(data[i : i+length]) 99 | env = append(env, entry) 100 | 101 | i += length 102 | } 103 | 104 | logger.Printf("Command Env: %v\n", env) 105 | 106 | return env 107 | } 108 | 109 | func readPacket() (Packet, error) { 110 | var readErr error 111 | var length uint32 112 | var tag uint8 113 | 114 | buf := make([]byte, BufferSize) 115 | 116 | length, readErr = readUint32(os.Stdin) 117 | if readErr == io.EOF { 118 | return Packet{}, io.EOF 119 | } else if readErr != nil { 120 | return Packet{}, readErr 121 | } 122 | 123 | dataLen := length - 1 124 | if dataLen < 0 || dataLen > BufferSize { // payload must be atleast tag size 125 | return Packet{}, errors.New("input payload size is invalid") 126 | } 127 | 128 | tag, readErr = readUint8(os.Stdin) 129 | if readErr != nil { 130 | return Packet{}, readErr 131 | } 132 | 133 | _, readErr = io.ReadFull(os.Stdin, buf[:dataLen]) 134 | if readErr != nil { 135 | return Packet{}, readErr 136 | } 137 | 138 | return Packet{tag, buf[:dataLen]}, nil 139 | } 140 | 141 | var buf = make([]byte, BufferSize+5) 142 | 143 | func writePacket(tag uint8, data []byte) { 144 | payloadLen := len(data) + 1 145 | 146 | writeUint32Be(buf[:4], uint32(payloadLen)) 147 | writeUint8Be(buf[4:5], tag) 148 | copy(buf[5:], data) 149 | 150 | _, writeErr := os.Stdout.Write(buf[:payloadLen+4]) 151 | if writeErr != nil { 152 | switch writeErr.(type) { 153 | // ignore broken pipe or closed pipe errors 154 | case *os.PathError: 155 | return 156 | default: 157 | fatal(writeErr) 158 | } 159 | } 160 | // logger.Printf("stdout written bytes: %v\n", bytesWritten) 161 | } 162 | 163 | func readUint32(stdin io.Reader) (uint32, error) { 164 | var buf [4]byte 165 | 166 | bytesRead, readErr := io.ReadFull(stdin, buf[:]) 167 | if readErr != nil { 168 | return 0, io.EOF 169 | } else if bytesRead == 0 { 170 | return 0, readErr 171 | } 172 | return binary.BigEndian.Uint32(buf[:]), nil 173 | } 174 | 175 | func readUint8(stdin io.Reader) (uint8, error) { 176 | var buf [1]byte 177 | 178 | bytesRead, readErr := io.ReadFull(stdin, buf[:]) 179 | if readErr != nil { 180 | return 0, io.EOF 181 | } else if bytesRead == 0 { 182 | return 0, readErr 183 | } 184 | return uint8(buf[0]), nil 185 | } 186 | 187 | func writeUint32Be(data []byte, num uint32) { 188 | binary.BigEndian.PutUint32(data, num) 189 | } 190 | 191 | func writeUint8Be(data []byte, num uint8) { 192 | data[0] = byte(num) 193 | } 194 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | ) 8 | 9 | func die(reason string) { 10 | if logger != nil { 11 | logger.Printf("dying: %v\n", reason) 12 | } 13 | fmt.Fprintln(os.Stderr, reason) 14 | os.Exit(-1) 15 | } 16 | 17 | func dieUsage(reason string) { 18 | if logger != nil { 19 | logger.Printf("dying: %v\n", reason) 20 | } 21 | 22 | writeStartError(reason) 23 | 24 | fmt.Fprintf(os.Stderr, "%v\n%v\n", reason, usage) 25 | os.Exit(-1) 26 | } 27 | 28 | func fatal(any interface{}) { 29 | if logger == nil { 30 | fmt.Fprintf(os.Stderr, "%v\n", any) 31 | os.Exit(-1) 32 | } 33 | logger.Panicf("%v\n", any) 34 | } 35 | 36 | func fatalIf(any interface{}) { 37 | if logger == nil { 38 | fmt.Fprintf(os.Stderr, "%v\n", any) 39 | os.Exit(-1) 40 | } 41 | if any != nil { 42 | logger.Panicf("%v\n", any) 43 | } 44 | } 45 | 46 | type NullReadWriteCloser struct { 47 | Signal chan struct{} 48 | } 49 | 50 | func (w NullReadWriteCloser) Write(p []byte) (n int, err error) { 51 | select { 52 | case <-w.Signal: 53 | return 0, new(os.PathError) 54 | default: 55 | return len(p), nil 56 | } 57 | } 58 | 59 | func (w NullReadWriteCloser) Read(p []byte) (n int, err error) { 60 | select { 61 | case <-w.Signal: 62 | return 0, io.EOF 63 | } 64 | } 65 | 66 | func (w NullReadWriteCloser) Close() (err error) { 67 | select { 68 | case <-w.Signal: 69 | default: 70 | close(w.Signal) 71 | } 72 | return nil 73 | } 74 | --------------------------------------------------------------------------------