commit 7a67749f1f240c7059d0d58bda89755825cef118 Author: ge Date: Sun Dec 28 20:42:30 2025 +0300 init diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..01072ca --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.v] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9a98968 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf +*.bat eol=crlf + +*.v linguist-language=V +*.vv linguist-language=V +*.vsh linguist-language=V +v.mod linguist-language=V +.vdocignore linguist-language=ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..897c618 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Binaries for programs and plugins +main +runcmd +*.exe +*.exe~ +*.so +*.dylib +*.dll + +# Ignore binary output folders +bin/ + +# Ignore common editor/system specific metadata +.DS_Store +.idea/ +.vscode/ +*.iml + +# ENV +.env + +# vweb and database +*.db +*.js + +TODO diff --git a/.vdocignore b/.vdocignore new file mode 100644 index 0000000..d838da9 --- /dev/null +++ b/.vdocignore @@ -0,0 +1 @@ +examples/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c4bb87 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Run External Commands + +`runcmd` module implements high-level interface for running external commands. + +## Why not vlib `os`? + +The standard V `os` module already contains tools for a similar tasks — `os.Process`, +`os.Command`, but I don't like any of them. There is also many functions for executing +external commands: + +* `os.execvp()`, `os.execve()` — cross-platform versions of the C functions of the same name. + +* `os.execute()`, `os.execute_opt()`, `os.execute_or_exit()`, `os.execute_or_panic()` — wrap C calls and wait for a command to completed. Under the hood, they perform a dirty hack by calling `sh` with stream redirection `'exec 2>&1;${cmd}'`. Only stdout and exit_code are available in result. + +* `os.system()` — also executes command in the shell, but does not redirect streams. This is fine for running commands that take a long time and write something to the terminal; it's convenient in build scripts. + +* `os.Process` just has an ugly interface with a lot of unnecessary methods. Actually, it's not bad; I copied parts of it. + +* `os.Command` runs `popen()` under the hood and is not suitable for anything other than running a command in the shell (again) with stream processing of the mixed stdout and stderr. + +This `runcmd` module is inspired by os/exec from the Go standard library and provides +a fairly flexible interface for starting child processes. + +The obvious downside of this module is that it only works on Linux and likely other +POSIX-compliant operating systems. I'm not interested in working on MS Windows, but +anyone interested can submit a PR on GitHub to support the worst operating system. + +## Usage + +Basic usage: + +```v +import runcmd + +mut cmd := runcmd.new('sh', '-c', 'echo Hello, World!') +cmd.run()! // Start and wait for process. +// Hello, World! +println(cmd.state) // exit status 0 +``` + +You can create a `Command` object directly if that's more convenient. The following +example is equivalent to the first: + +```v +import runcmd + +mut cmd := runcmd.Command{ + path: 'sh' // automatically resolves to actual path, e.g. /usr/bin/sh + args: ['-c', 'echo Hello, World!'] +} +cmd.run()! +println(cmd.state) +``` + +If you don't want to wait for the child process to complete, call `start()` instead of `run()`: + +```v +mut cmd := runcmd.new('sh', '-c', 'sleep 60') +pid := cmd.start()! +println(pid) +``` +`.state` value is unavailable in this case because we didn't wait for the process to complete. + +If you need to capture standard output and standard error, use the `output()` and +`combined_output()`. See examples in its description. + +See also [examples](examples) dir for more examples. + +## Roadmap + +- [x] Basic implementation. +- [ ] Contexts support for creating cancelable commands, commands with timeouts, etc. +- [ ] Process groups support, pgkill(). +- [ ] Better error handling and more tests... + +Send pull requests for additional features/bugfixes. diff --git a/UNLICENSE b/UNLICENSE new file mode 100644 index 0000000..c91541e --- /dev/null +++ b/UNLICENSE @@ -0,0 +1,22 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +software, either in source code form or as a compiled binary, for any purpose, +commercial or non-commercial, and by any means. + +In jurisdictions that recognize copyright laws, the author or authors of this +software dedicate any and all copyright interest in the software to the public +domain. We make this dedication for the benefit of the public at large and to +the detriment of our heirs and +successors. We intend this dedication to be an overt act of relinquishment in +perpetuity of all present and future rights to this software under copyright +law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/bytebuf.v b/bytebuf.v new file mode 100644 index 0000000..d64cf8f --- /dev/null +++ b/bytebuf.v @@ -0,0 +1,26 @@ +module runcmd + +import io + +// buffer creates simple bytes buffer that can be read through `io.Reader` interface. +pub fn buffer(data []u8) ByteBuffer { + return ByteBuffer{ + bytes: data + } +} + +struct ByteBuffer { + bytes []u8 +mut: + pos int +} + +// read reads `buf.len` bytes from internal bytes buffer and returns number of bytes read. +pub fn (mut b ByteBuffer) read(mut buf []u8) !int { + if b.pos >= b.bytes.len { + return io.Eof{} + } + n := copy(mut buf, b.bytes[b.pos..]) + b.pos += n + return n +} diff --git a/cmd.v b/cmd.v new file mode 100644 index 0000000..c1625a4 --- /dev/null +++ b/cmd.v @@ -0,0 +1,374 @@ +module runcmd + +import io +import os +import strings + +type IOCopyFn = fn () ! + +@[heap] +pub struct Command { +pub mut: + // path may be a command name or absolute path to executable. + // If the specified path is not absolute, it will be obtained + // using the look_path() function before starting the process. + path string + + // args holds command line arguments passed to the executable. + args []string + + // env contains key-value pairs of environment variables that + // will be passed to the process. If not specified the current + // os.environ() will be used. If you want to update current + // environ instead of overriding it, use merge() function from + // `maps` module: `maps.merge(os.environ(), {'MYENV': 'value'})` + env map[string]string + + // dir specifies the current working directory for the child + // process. If not specified, the current working directory will + // be used. + dir string + + // If true create pipes for standart in/out/err streams and + // duplicate child streams to created file descriptors. + // redirect_stdio is required for work with child I/O. + redirect_stdio bool + + // If the field is filled (any of stdin, stdout, stderr), then + // after the process is started, a coroutine will be launched + // in the background, which will copy the corresponding data + // stream between the child and parent processes. + // + // This fields must be set BEFORE calling the start() function. + // + // This is useful if we want to feed data to the child process + // via standard input or read stdout and/or stderr entirely. + // Since the coroutines copies the stream continuously in a loop, + // the data can only be processed once it has been completely + // read. To process data in chunks, do not set these fields, but + // use the stdin(), stdout() and stderr() functions to obtain + // the child process's file descriptors after calling start(). + stdin ?io.Reader + stdout ?io.Writer + stderr ?io.Writer + + // state holds an information about underlying process. + // This is set only if process if finished. Call `run()` + // or `wait()` to get actual state value. + // This value MUST NOT be changed by API user. + state ProcessState +mut: + // process holds the underlying Process. + process &Process = unsafe { nil } + + // stdio holds a file descriptors for I/O processing. + // There is: + // * 0 — child process stdin, we must write into it. + // * 1 — child process stdout, we must read from it. + // * 2 — child process stderr, we must read from it. + stdio [3]int = [-1, -1, -1]! + + // stdio_copy_fns is array of closures to copy data between + // parent and child processes. For standard I/O streams + // it does (if some of .stdin, .stdout and .stderr fields is set): + // * read from .stdin reader and write data into child stdin fd. + // * read from child stdout fd and write into .stdout writer. + // * read from child stderr fd and write into .stderr writer. + stdio_copy_fns []IOCopyFn +} + +// run starts a specified command and waits for it. After call see the .state +// value to get finished process identifier, exit status and other attributes. +// `run()` is shorthand for: +// ```v +// cmd.start()! +// cmd.wait()! +// ``` +pub fn (mut c Command) run() ! { + c.start()! + c.wait()! +} + +// output runs the command and returns its stdout on success. If command exit +// status is non-zero `ExitError` error is returned. +// Example: +// ```v +// mut okcmd := runcmd.new('sh', '-c', 'echo Hello, World!') +// output := okcmd.output()! +// // Hello, World! +// +// mut badcmd := runcmd.new('sh', '-c', 'echo -n Error! >&2; false') +// output := badcmd.output() or { +// if err is runcmd.ExitError { +// eprintln(err) +// exit(err.code()) +// } else { +// // error starting process or handling I/O, see errno in err.code(). +// panic(err) +// } +// } +// // &runcmd.ExitError{ +// // state: exit status 1 +// // stderr: 'Error!' +// // } +// ``` +pub fn (mut c Command) output() !string { + mut out := strings.new_builder(4096) + mut err := strings.new_builder(4096) + c.redirect_stdio = true + c.stdout = out + c.stderr = err + c.start()! + c.wait()! + if !c.state.success() { + return ExitError{ + state: c.state + stderr: err.str() + } + } + return out.str() +} + +// combined_output runs the command and returns its combined stdout and stderr. +// Unlike `output()`, this function does not return `ExitError` on command failure. +// Note: The order of lines from stdout and stderr is not guaranteed, since +// reading from the corresponding file descriptors is done concurrently. +// Example: +// ```v +// mut cmd := runcmd.new('sh', '-c', 'echo Hello, STDOUT!; echo Hello, STDERR! >&2') +// output := cmd.combined_output()! +// // Hello, STDOUT! +// // Hello, STDERR! +// ``` +pub fn (mut c Command) combined_output() !string { + mut out := strings.new_builder(4096) + c.redirect_stdio = true + c.stdout = out + c.stderr = out + c.start()! + c.wait()! + return out.str() +} + +// start starts a specified command and does not wait for it to complete. Call +// `wait()` after `start()` has successfully completed to wait for the command +// to complete and release associated resources. +// Note: `.state` field is not set after `start()` call. +pub fn (mut c Command) start() !int { + if !isnil(c.process) { + return error('runcmd: process already started') + } + + mut pipes := [3]Pipe{} + if c.redirect_stdio { + pipes[0] = pipe()! // stdin + pipes[1] = pipe()! // stdout + pipes[2] = pipe()! // stderr + } + + post_fork_parent_cb := fn [mut c, pipes] (mut p Process) ! { + if !c.redirect_stdio { + return + } + c.stdio[0] = pipes[0].w + c.stdio[1] = pipes[1].r + c.stdio[2] = pipes[2].r + fd_close(pipes[0].r)! + fd_close(pipes[1].w)! + fd_close(pipes[2].w)! + } + + post_fork_child_cb := fn [mut c, pipes] (mut p Process) ! { + if !c.redirect_stdio { + return + } + fd_close(pipes[0].w)! + fd_close(pipes[1].r)! + fd_close(pipes[2].r)! + fd_dup2(pipes[0].r, 0)! + fd_dup2(pipes[1].w, 1)! + fd_dup2(pipes[2].w, 2)! + fd_close(pipes[0].r)! + fd_close(pipes[1].w)! + fd_close(pipes[2].w)! + } + + if c.redirect_stdio { + if c.stdin != none { + c.stdio_copy_fns << fn [mut c] () ! { + printdbg('Command.start: stdin copy callback called') + mut fd := c.stdin()! + printdbg('Command.start: stdin copy callback: child stdin fd=${fd.fd}') + if c.stdin != none { + // FIXME: V bug?: without `if` guard acessing + // to c.stdin causes SIGSEGV. + io_copy(mut c.stdin, mut fd, 'copy stdin')! + printdbg('Command.start: stdin copy callback: close child stdin fd after copy') + fd_close(fd.fd)! + } + } + } + if c.stdout != none { + c.stdio_copy_fns << fn [mut c] () ! { + printdbg('Command.start: stdout copy callback called') + mut fd := c.stdout()! + if c.stdout != none { + io_copy(mut fd, mut c.stdout, 'copy stdout')! + } + } + } + if c.stderr != none { + c.stdio_copy_fns << fn [mut c] () ! { + printdbg('Command.start: stderr copy callback called') + mut fd := c.stderr()! + if c.stderr != none { + io_copy(mut fd, mut c.stderr, 'copy stderr')! + } + } + } + } + + // Prepare and start child process. + path := look_path(c.path)! + c.path = path + c.process = &Process{ + path: path + argv: c.args + env: if c.env.len == 0 { os.environ() } else { c.env } + dir: if c.dir == '' { os.getwd() } else { c.dir } + post_fork_parent_cb: post_fork_parent_cb + post_fork_child_cb: post_fork_child_cb + } + + pid := c.process.start()! + + // Start I/O copy callbacks. + if c.stdio_copy_fns.len > 0 { + for f in c.stdio_copy_fns { + go fn (func IOCopyFn) { + printdbg('Command.start: starting I/O copy closure in coroutine') + func() or { eprintln('error in I/O copy coroutine: ${err}') } + }(f) + } + } + + // TODO: Handle context here... + return pid +} + +// wait waits to previously started command is finished. After call see the `.state` +// field value to get finished process identifier, exit status and other attributes. +// `wait()` will return an error if the process has not been started or wait has +// already been called. +pub fn (mut c Command) wait() ! { + if isnil(c.process) { + return error('runcmd: wait for non-started process') + } else if c.state != ProcessState{} { + return error('runcmd: wait already called') + } + c.state = c.process.wait()! + unsafe { c.release()! } +} + +// release releases all resources assocuated with process. +@[unsafe] +pub fn (mut c Command) release() ! { + for fd in c.stdio { + if fd == -1 { + continue + } + fd_close(fd) or { + if err.code() == 9 { + // Ignore EBADF error, fd is already closed. + continue + } + printdbg('${@METHOD}: cannot close fd: ${err}') + return err + } + } +} + +// stdin returns an open file descriptor associated with the standard +// input stream of the child process. This descriptor is writable only +// by the parent process. +pub fn (c Command) stdin() !WriteFd { + return if c.stdio[0] != -1 { + WriteFd{c.stdio[0]} + } else { + printdbg('${@METHOD}: invalid fd -1') + error_with_code('Bad file descriptor', 9) + } +} + +// stdout returns an open file descriptor associated with the standard +// output stream of the child process. This descriptor is read-only for +// the parent process. +pub fn (c Command) stdout() !ReadFd { + return if c.stdio[1] != -1 { + ReadFd{c.stdio[1]} + } else { + printdbg('${@METHOD}: invalid fd -1') + error_with_code('Bad file descriptor', 9) + } +} + +// stderr returns an open file descriptor associated with the standard +// error stream of the child process. This descriptor is read-only for +// the parent process. +pub fn (c Command) stderr() !ReadFd { + return if c.stdio[2] != -1 { + ReadFd{c.stdio[2]} + } else { + printdbg('${@METHOD}: invalid fd -1') + error_with_code('Bad file descriptor', 9) + } +} + +pub struct ExitError { +pub: + state ProcessState + stderr string +} + +// code returns an exit status code of a failed process. +pub fn (e ExitError) code() int { + return e.state.exit_code() +} + +// msg returns message about command failure. +pub fn (e ExitError) msg() string { + return 'command exited with non-zero code' +} + +// io_copy is copypasta from io.cp() with some debug logs. +fn io_copy(mut src io.Reader, mut dst io.Writer, msg string) ! { + mut buf := []u8{len: 4096} + defer { + unsafe { + buf.free() + } + } + for { + nr := src.read(mut buf) or { + printdbg('${@FN}: (${msg}) got error from reader, breaking loop: ${err}') + break + } + printdbg('${@FN}: (${msg}) ${nr} bytes read from src to buf') + nw := dst.write(buf[..nr]) or { return err } + printdbg('${@FN}: (${msg}) ${nw} bytes written to dst') + } +} + +// Wrap os.fd_* functions for errors handling... + +fn fd_close(fd int) ! { + if os.fd_close(fd) == -1 { + return os.last_error() + } +} + +fn fd_dup2(fd1 int, fd2 int) ! { + if os.fd_dup2(fd1, fd2) == -1 { + return os.last_error() + } +} diff --git a/dbg.v b/dbg.v new file mode 100644 index 0000000..c3bfcdd --- /dev/null +++ b/dbg.v @@ -0,0 +1,6 @@ +module runcmd + +@[if runcmd_trace ?] +fn printdbg(s string) { + eprintln('runcmd[pid=${v_getpid()}]: ${s}') +} diff --git a/examples/error_handling.v b/examples/error_handling.v new file mode 100644 index 0000000..83ecadd --- /dev/null +++ b/examples/error_handling.v @@ -0,0 +1,44 @@ +import os +import runcmd + +fn main() { + // Prepare the command. + mut cmd := runcmd.new('sh', '-c', 'echo -n This command always fails! >&2; sleep 30; false') + + // Run this example with `-d runcmd_trace` to see debug logs. + // Look for line like this: runcmd[pid=584015]: Process.wait: wait for pid 584016 + // Try to `kill -9 ${pid_here}` while program runs and see whats happen. + + // Run command with capturing its output. + out := cmd.output() or { + if err is runcmd.ExitError { + // Command exited with non-zero code. Handle it here. + eprintln(err) + // `err.state` can tell you the failure details. + eprintln(err.state) + // Let's check if the process was killed by someone... + status := runcmd.WaitStatus(err.state.sys()) + if status.term_signal() == int(os.Signal.kill) { + eprintln('Oh, process is killed... ( x__x )') + } else { + // Not killed. + } + exit(err.code()) // `err.code()` here contains the command exit status. + } else { + // Another error occurred. Most likely, something went wrong while executing + // the process creation system calls. Check `err.code()` to get the concrete + // error, it contains the standard C errno value. + // See https://www.man7.org/linux/man-pages/man3/errno.3.html + + // Replace 0 to actual errno value (real errno never be zero). + if err.code() == 0 { + // Do something here... + } + + // Fallback to panic. + panic(err) + } + } + + println(out) +} diff --git a/examples/stream_input_and_output.v b/examples/stream_input_and_output.v new file mode 100644 index 0000000..340f429 --- /dev/null +++ b/examples/stream_input_and_output.v @@ -0,0 +1,59 @@ +import io.string_reader +import rand +import runcmd +import time + +fn main() { + // Prepare the command. + mut cmd := runcmd.new('cat') + + // Setup I/O redirection. + cmd.redirect_stdio = true + + // Start a process. + // Note: File descriptors will only become available after the process has started! + pid := cmd.start()! + println('Child process started with pid ${pid}') + + // Get child file descriptors. + mut child_stdin := cmd.stdin()! + mut child_stdout := cmd.stdout()! + + // Prepare reader to store command output. + mut output := string_reader.StringReader.new(reader: child_stdout) + + // Start stdout reading in a coroutine. + // + // The reader will be block until the descriptor contains data. + // Therefore, to avoid blocking the main thread, we start the reader + // in a coroutine. + go fn [mut output] () { + println('STDOUT reader started!') + // Read stdout line by line until EOF. + for { + line := output.read_line() or { break } + println('Recv: ${line}') + } + }() + + // Start sending data to child in a loop. + limit := 5 + for _ in 0 .. limit { + // Generate some data. + data := rand.string(10) + '\n' + print('Send: ${data}') + + // Write data to child stdin file descriptor. + _ := child_stdin.write(data.bytes())! + + // Sleep a bit for demonstration. + time.sleep(500 * time.millisecond) + } + + // Close stdin by hand so that the child process receives EOF. + // Without this child will hang for waiting for input. + child_stdin.close()! + + // wait() will close the child stdout file desciptor by itself. + cmd.wait()! +} diff --git a/examples/stream_output.v b/examples/stream_output.v new file mode 100644 index 0000000..428bbba --- /dev/null +++ b/examples/stream_output.v @@ -0,0 +1,28 @@ +import io.string_reader +import runcmd + +fn main() { + // Prepare command. + mut cmd := runcmd.new('sh', '-c', r'for i in {1..5}; do echo line $i; sleep .5; done; echo finish!') + + // This is required to captute standart I/O streams. + cmd.redirect_stdio = true + + // Start child process. + pid := cmd.start()! + println('Child process started with pid ${pid}') + + // Setup StringReader with stdout input. Note the cmd.stdout()! call, it + // returns the io.Reader interface and reads child process stdout file descriptor. + mut reader := string_reader.StringReader.new(reader: cmd.stdout()!) + + // Read sdtout line by line until EOF. + for { + line := reader.read_line() or { break } + println('Read: ${line}') + } + + cmd.wait()! // Wait to child process completed. + + println('Child state: ${cmd.state}') +} diff --git a/examples/write_to_child_stdin.v b/examples/write_to_child_stdin.v new file mode 100644 index 0000000..5576db8 --- /dev/null +++ b/examples/write_to_child_stdin.v @@ -0,0 +1,38 @@ +import io.string_reader +import strings +import runcmd + +fn main() { + input := 'Hello from parent process!' + + // Prepare reader and writer. + // + // * `reader` reads input from the parent process; it will be copied to the + // standard input of the child process. + // * `writer` accepts data from the child process; it will be copied from the + // standard output of the child process. + mut reader := string_reader.StringReader.new(reader: runcmd.buffer(input.bytes()), source: input) + mut writer := strings.new_builder(4096) + + // Prepare the command. + mut cmd := runcmd.new('cat') + + // Set redirect_stdio to perform I/O copying between parent and child processes. + cmd.redirect_stdio = true + + // Setup reader and writer for child I/O streams. + cmd.stdin = reader + cmd.stdout = writer + + // Start and wait for command. + cmd.run()! + + // Get command output as string. + output := writer.str() + + // Make sure that `cat` returned the same data that we sent to it as input. + assert input == output, 'output data differs from input!' + + println('Child state: ${cmd.state}') + println('Child output: ${output}') +} diff --git a/fdio.v b/fdio.v new file mode 100644 index 0000000..60dddd9 --- /dev/null +++ b/fdio.v @@ -0,0 +1,73 @@ +module runcmd + +import io +import os + +struct ReadFd { + fd int +} + +// read reads the `buf.len` bytes from file descriptor and returns number of +// bytes read on success. This function implements the `io.Reader` interface. +pub fn (mut f ReadFd) read(mut buf []u8) !int { + if buf.len == 0 { + return io.Eof{} + } + nbytes := int(C.read(f.fd, buf.data, buf.len)) + if nbytes == -1 { + return os.last_error() + } + if nbytes == 0 { + return io.Eof{} + } + return nbytes +} + +// slurp reads all data from file descriptor (until gets `io.Eof`) and returns +// result as byte array. +pub fn (mut f ReadFd) slurp() ![]u8 { + mut res := []u8{} + bufsize := 4096 + for { + mut buf := []u8{len: bufsize, cap: bufsize} + nbytes := f.read(mut buf) or { + if err is io.Eof { + break + } else { + return err + } + } + if nbytes == 0 { + break + } + res << buf + } + return res +} + +// close closes the underlying file descriptor. +pub fn (mut f ReadFd) close() ! { + fd_close(f.fd)! +} + +struct WriteFd { + fd int +} + +// write writes the `buf.len` bytes to the file descriptor and returns number +// of bytes written on success. This function implements the `io.Writer` interface. +pub fn (mut f WriteFd) write(buf []u8) !int { + if buf.len == 0 { + return 0 + } + nbytes := int(C.write(f.fd, buf.data, buf.len)) + if nbytes == -1 { + return os.last_error() + } + return nbytes +} + +// close closes the underlying file descriptor. +pub fn (mut f WriteFd) close() ! { + fd_close(f.fd)! +} diff --git a/pipe.c.v b/pipe.c.v new file mode 100644 index 0000000..8b73d2b --- /dev/null +++ b/pipe.c.v @@ -0,0 +1,17 @@ +module runcmd + +import os + +struct Pipe { +pub: + r int = -1 + w int = -1 +} + +fn pipe() !Pipe { + mut fds := [2]int{} + if C.pipe(&fds[0]) == -1 { + return os.last_error() + } + return Pipe{fds[0], fds[1]} +} diff --git a/proc.c.v b/proc.c.v new file mode 100644 index 0000000..b587c6f --- /dev/null +++ b/proc.c.v @@ -0,0 +1,160 @@ +module runcmd + +import os + +pub type ProcCallbackFn = fn (mut p Process) ! + +pub struct Process { +pub: + // Absolute path to the executable. + path string + + // Arguments that will be passed to the executable. + argv []string + + // Environment variables that will be applied to the child process. + env map[string]string + + // Working directory for the child process. + dir string + + // The *_cb fields stores callback functions that will be executed respectively: + // - before calling fork(); + // - after calling fork() in the parent process, until the function exits; + // - after calling fork() in the child process, until the working directory is + // changed and execve() is called. + pre_fork_cb ProcCallbackFn = fn (mut p Process) ! {} + post_fork_parent_cb ProcCallbackFn = fn (mut p Process) ! {} + post_fork_child_cb ProcCallbackFn = fn (mut p Process) ! {} +mut: + pid int = -1 +} + +struct ProcessState { + pid int = -1 + status WaitStatus = -1 +} + +// pid returns the child process identifier. If process is not +// launched yet -1 wil be returned. +pub fn (s ProcessState) pid() int { + return s.pid +} + +// exited returns true if process is exited. +pub fn (s ProcessState) exited() bool { + return s.status.exited() +} + +// exit_code returns the process exit status code or -1 if process is not exited. +pub fn (s ProcessState) exit_code() int { + return s.status.exit_code() +} + +// success returns true if process if successfuly exited (0 exit status on POSIX). +pub fn (s ProcessState) success() bool { + return s.status.exit_code() == 0 +} + +// sys returns the system-specific process state object. For now its always `WaitStatus`. +pub fn (s ProcessState) sys() voidptr { + // FIXME: Possible V bug: return without explicit voidptr cast corrupts the value... + // Reproduces with examples/error_handling.v in SIGKILL check. + // return &s.status + return unsafe { voidptr(s.status) } +} + +// str returns the text representation of process state. For non-started process +// it returns 'unknown' state. +pub fn (s ProcessState) str() string { + mut str := '' + match true { + s.exited() { + str = 'exit status ${s.exit_code()}' + } + s.status.signaled() { + sig := s.status.term_signal() + sig_str := os.sigint_to_signal_name(sig) + str = 'signal: ${sig} (${sig_str})' + } + s.status.stopped() { + str = 'stop signal: ${s.status.stop_signal()}' + } + s.status.continued() { + str = 'continued' + } + else { + str = 'unknown' + } + } + if s.status.coredump() { + str += ' (core dumped)' + } + return str +} + +// start starts new child process by performing +// [fork(3p)](https://www.man7.org/linux/man-pages/man3/fork.3p.html) and +// [execve(3p)](https://man7.org/linux/man-pages/man3/exec.3p.html) +// calls. Return value is the child process identifier. +pub fn (mut p Process) start() !int { + printdbg('${@METHOD}: current pid before fork() = ${v_getpid()}') + printdbg('${@METHOD}: executing pre-fork callback') + p.pre_fork_cb(mut p)! + pid := os.fork() + p.pid = pid + printdbg('${@METHOD}: pid after fork() = ${pid}') + + if pid != 0 { + // + // This is the parent process after the fork + // + printdbg('${@METHOD}: executing post-fork parent callback') + p.post_fork_parent_cb(mut p)! + return pid + } + // + // This is the child process + // + printdbg('${@METHOD}: executing post-fork child callback') + p.post_fork_child_cb(mut p)! + if p.dir != '' { + os.chdir(p.dir)! + } + mut env := []string{} + for k, v in p.env { + env << k + '=' + v + } + os.execve(p.path, p.argv, env)! + return pid +} + +// pid returns the child process identifier. -1 is returned if process is not started. +pub fn (p &Process) pid() int { + return p.pid +} + +// wait waits for process to change state and returns the `ProcessState`. +pub fn (p &Process) wait() !ProcessState { + printdbg('${@METHOD}: wait for pid ${p.pid}') + mut wstatus := 0 + if C.waitpid(p.pid, &wstatus, 0) == -1 { + return os.last_error() + } + return ProcessState{ + pid: p.pid + status: wstatus + } +} + +// signal sends the `sig` signal to the child process. +pub fn (p &Process) signal(sig os.Signal) ! { + if C.kill(p.pid, int(sig)) == -1 { + return os.last_error() + } +} + +// kill send SIGKILL to the child process. +pub fn (p &Process) kill() ! { + p.signal(.kill)! +} diff --git a/runcmd.v b/runcmd.v new file mode 100644 index 0000000..fb1a739 --- /dev/null +++ b/runcmd.v @@ -0,0 +1,64 @@ +module runcmd + +import os + +// new creates new Command instance with given command name and arguments. +pub fn new(name string, arg ...string) &Command { + return &Command{ + path: name + args: arg + } +} + +// is_present returns true if cmd is present on system. cmd may be a command +// name or filepath (relative or absolute). +// The result relies on `look_path()` output, see its docs for command search +// details. +pub fn is_present(cmd string) bool { + _ := look_path(cmd) or { return false } + return true +} + +// look_path returns the absolute path to executable file. cmd may be a command +// name or filepath (relative or absolute). If the name contains a slash, then the +// PATH search is not performed, instead the path will be resolved and the file +// existence and its permissions will be checked (execution must be allowed). +// Note: To use executables located in the current working directory use './file' +// instead of just 'file'. Searching for executable files in the current directory +// is disabled for security reasons. See https://go.dev/blog/path-security. +pub fn look_path(cmd string) !string { + if cmd.is_blank() { + return os.ExecutableNotFoundError{} + } + + // Do not search executable in PATH if its name contains a slashes (as POSIX-shells does), + // See PATH in: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html#tag_08_03 + + if cmd.contains('/') { + actual_path := os.abs_path(os.expand_tilde_to_home(os.norm_path(cmd))) + if is_executable_file(actual_path) { + return actual_path + } else { + return os.ExecutableNotFoundError{} + } + } + + paths := os.getenv('PATH').split(os.path_delimiter) + + for path in paths { + if path in ['', '.'] { + // Prohibit current directory. + continue + } + actual_path := os.abs_path(os.join_path_single(path, cmd)) + if is_executable_file(actual_path) { + return actual_path + } + } + + return os.ExecutableNotFoundError{} +} + +fn is_executable_file(file string) bool { + return os.is_file(file) && os.is_executable(file) +} diff --git a/runcmd_test.v b/runcmd_test.v new file mode 100644 index 0000000..20b5f4e --- /dev/null +++ b/runcmd_test.v @@ -0,0 +1,24 @@ +import runcmd +import os +import io.util + +fn make_temp_file() !string { + _, path := util.temp_file()! + os.chmod(path, 0o700)! + dump(path) + return path +} + +fn test_lookup() { + path := make_temp_file()! + defer { os.rm(path) or {} } + assert os.is_abs_path(runcmd.look_path(path)!) + assert runcmd.look_path('/nonexistent') or { '' } == '' + assert runcmd.look_path('env')! == '/usr/bin/env' +} + +fn test_is_present() { + path := make_temp_file()! + defer { os.rm(path) or {} } + assert runcmd.is_present(path) +} diff --git a/v.mod b/v.mod new file mode 100644 index 0000000..aa29f64 --- /dev/null +++ b/v.mod @@ -0,0 +1,7 @@ +Module { + name: 'runcmd' + description: 'Run external commands' + version: '0.1.0' + license: 'Unlicense' + dependencies: [] +} diff --git a/wait.c.v b/wait.c.v new file mode 100644 index 0000000..e4f6f81 --- /dev/null +++ b/wait.c.v @@ -0,0 +1,69 @@ +module runcmd + +@[trusted] +fn C.WIFSTOPPED(int) bool + +@[trusted] +fn C.WCOREDUMP(int) bool + +@[trusted] +fn C.WIFCONTINUED(int) bool + +@[trusted] +fn C.WSTOPSIG(int) int + +// WaitStatus stores the result value of [wait(2)](https://www.man7.org/linux/man-pages/man2/wait.2.html) syscall. +pub type WaitStatus = u32 + +// exited returns true if process is exited. +pub fn (w WaitStatus) exited() bool { + return C.WIFEXITED(w) +} + +// exit_code returns the process exit status code or -1 if process is not exited. +pub fn (w WaitStatus) exit_code() int { + if w.exited() { + return C.WEXITSTATUS(w) + } + return -1 +} + +// signaled returns true if the child process was terminated by a signal. +pub fn (w WaitStatus) signaled() bool { + return C.WIFSIGNALED(w) +} + +// term_signal returns the number of the signal that caused the child process to terminate. +pub fn (w WaitStatus) term_signal() int { + if w.signaled() { + return C.WTERMSIG(w) + } + return -1 +} + +// stopped returns true if the child process was stopped by delivery of a signal. +pub fn (w WaitStatus) stopped() bool { + return C.WIFSTOPPED(w) +} + +// stop_signal returns the number of the signal which caused the child to stop. +pub fn (w WaitStatus) stop_signal() int { + if w.stopped() { + return C.WSTOPSIG(w) + } + return -1 +} + +// continued returns true if the child process was resumed by delivery of SIGCONT. +pub fn (w WaitStatus) continued() bool { + return C.WIFCONTINUED(w) +} + +// coredump returns true if the child produced a core dump. +// See [core(5)](https://man7.org/linux/man-pages/man5/core.5.html). +pub fn (w WaitStatus) coredump() bool { + if w.signaled() { + return C.WCOREDUMP(w) + } + return false +}