13 Commits

Author SHA1 Message Date
ge
a43070aa95 mod: bump version 2026-01-08 09:58:26 +03:00
ge
fdc57e846a examples: add command_with_chroot.c.v 2026-01-08 09:57:02 +03:00
ge
e4c4c9ef87 feat,breaking: add pre_exec_hooks support, rework Process hooks 2026-01-08 09:56:47 +03:00
ge
a3b5d4def5 readme: upd 2026-01-08 09:55:32 +03:00
ge
4ccc80f4a5 doc: fix docs for look_path/1 2026-01-08 09:55:04 +03:00
ge
21ebb3dbfa fix: improve error handling in Process 2026-01-08 05:39:19 +03:00
ge
04faf33542 examples: update command_with_timeout.v, remove io.string_reader from write_to_child_string.v 2026-01-08 05:12:41 +03:00
ge
acd7a0cfc6 mod: bump version 2026-01-04 16:19:14 +03:00
ge
4df4fcec47 fix: allow start ctx_watch without Command.cancel fn 2026-01-04 16:16:46 +03:00
ge
8a4262dc1a mod: bump version, add repo_url 2026-01-03 19:18:32 +03:00
ge
3cd5e40ac8 readme: update 2026-01-03 19:18:15 +03:00
ge
055dab663e feat: contexts support 2026-01-03 19:17:50 +03:00
ge
b790cfef0a ci: Add CI 2025-12-28 20:53:35 +03:00
12 changed files with 424 additions and 90 deletions

48
.github/workflows/docs.yaml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Docs
on:
push:
branches: [ "master" ]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup V
run: |
wget -qO /tmp/v.zip https://github.com/vlang/v/releases/latest/download/v_linux.zip
unzip -q /tmp/v.zip -d /tmp
echo /tmp/v >> "$GITHUB_PATH"
- name: Build docs
run: |
v doc -f html -m .
pushd _docs
ln -vs ${{ github.event.repository.name }}.html index.html
ls -alFh
popd
- name: Upload static files as artifact
id: deployment
uses: actions/upload-pages-artifact@v3
with:
path: _docs/
deploy:
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
permissions:
contents: read
pages: write
id-token: write

27
.github/workflows/test.yaml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Lint and test
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup V
run: |
wget -qO /tmp/v.zip https://github.com/vlang/v/releases/latest/download/v_linux.zip
unzip -q /tmp/v.zip -d /tmp
echo /tmp/v >> "$GITHUB_PATH"
- name: Run tests
run: |
v fmt -verify .
v vet -v -W -I -F -r .
v missdoc -r --verify .
v -stats test .

View File

@@ -1,25 +1,23 @@
# Run External Commands
`runcmd` module implements high-level interface for running external commands.
`runcmd` module implements a 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:
The standard V `os` module already contains many tools for running external commands,
but I don't like any of them. So let's overview.
* `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.execute()`, `os.execute_opt()`, `os.execute_or_exit()`, `os.execute_or_panic()`starts and waits for a command to completed. Under the hood, they perform a dirty hack by calling shell with stream redirection `'exec 2>&1;${cmd}'`.
* `util.execute_with_timeout()` (from `os.util`) — just an `os.execute()` wrapper.
* `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.
* `os.Command` calls `C.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.
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
@@ -38,20 +36,6 @@ cmd.run()! // Start and wait for process.
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
@@ -64,12 +48,13 @@ println(pid)
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.
To learn more about the `runcmd`'s capabilities and usage, see the [examples](examples)
directory. **Examples are very important**.
## Roadmap
- [x] Basic implementation.
- [ ] Contexts support for creating cancelable commands, commands with timeouts, etc.
- [x] Contexts support for creating cancelable commands, commands with timeouts, etc.
- [ ] Process groups support, pgkill().
- [ ] Better error handling and more tests...

114
cmd.v
View File

@@ -1,11 +1,14 @@
module runcmd
import context
import io
import os
import strings
type IOCopyFn = fn () !
pub type CommandCancelFn = fn () !
@[heap]
pub struct Command {
pub mut:
@@ -24,9 +27,9 @@ pub mut:
// `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 specifies the working directory for the child process.
// If not specified, the current working directory of parent
// will be used.
dir string
// If true create pipes for standart in/out/err streams and
@@ -52,15 +55,34 @@ pub mut:
stdout ?io.Writer
stderr ?io.Writer
// ctx holds a command context. It may be used to make a command
// cancelable or set timeout/deadline for it.
ctx ?context.Context
// cancel function is used to terminate the child process. Do
// not confuse with the context's cancel function.
//
// The default command cancel function created in with_context()
// terminates the command by sending SIGTERM signal to child. You
// can override it by setting your own command cancel function.
// If cancel is none the child process won't be terminated even
// if context is timed out or canceled.
cancel ?CommandCancelFn
// pre_exec_hooks will be called before starting the command in
// the child process. Hooks can be used to modify a child's envi-
// ronment, for example to perform a chroot.
pre_exec_hooks []ProcessHookFn
// process holds the underlying Process once started.
process ?&Process
// 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.
@@ -77,7 +99,7 @@ mut:
stdio_copy_fns []IOCopyFn
}
// run starts a specified command and waits for it. After call see the .state
// 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
@@ -93,12 +115,15 @@ pub fn (mut c Command) run() ! {
// status is non-zero `ExitError` error is returned.
// Example:
// ```v
// import runcmd
//
// mut okcmd := runcmd.new('sh', '-c', 'echo Hello, World!')
// output := okcmd.output()!
// ok_out := okcmd.output()!
// println(ok_out)
// // Hello, World!
//
// mut badcmd := runcmd.new('sh', '-c', 'echo -n Error! >&2; false')
// output := badcmd.output() or {
// bad_out := badcmd.output() or {
// if err is runcmd.ExitError {
// eprintln(err)
// exit(err.code())
@@ -107,14 +132,15 @@ pub fn (mut c Command) run() ! {
// panic(err)
// }
// }
// println(bad_out)
// // &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)
mut out := strings.new_builder(2048)
mut err := strings.new_builder(2048)
c.redirect_stdio = true
c.stdout = out
c.stderr = err
@@ -135,8 +161,10 @@ pub fn (mut c Command) output() !string {
// reading from the corresponding file descriptors is done concurrently.
// Example:
// ```v
// import runcmd
// mut cmd := runcmd.new('sh', '-c', 'echo Hello, STDOUT!; echo Hello, STDERR! >&2')
// output := cmd.combined_output()!
// println(output)
// // Hello, STDOUT!
// // Hello, STDERR!
// ```
@@ -155,7 +183,7 @@ pub fn (mut c Command) combined_output() !string {
// 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) {
if c.process != none {
return error('runcmd: process already started')
}
@@ -166,7 +194,7 @@ pub fn (mut c Command) start() !int {
pipes[2] = pipe()! // stderr
}
post_fork_parent_cb := fn [mut c, pipes] (mut p Process) ! {
parent_pipes_hook := fn [mut c, pipes] (mut p Process) ! {
if !c.redirect_stdio {
return
}
@@ -178,7 +206,8 @@ pub fn (mut c Command) start() !int {
fd_close(pipes[2].w)!
}
post_fork_child_cb := fn [mut c, pipes] (mut p Process) ! {
child_pipes_hook := fn [mut c, pipes] (mut p Process) ! {
printdbg('child pipes hook!')
if !c.redirect_stdio {
return
}
@@ -193,6 +222,9 @@ pub fn (mut c Command) start() !int {
fd_close(pipes[2].w)!
}
mut pre_exec_hooks := [child_pipes_hook]
pre_exec_hooks << c.pre_exec_hooks
if c.redirect_stdio {
if c.stdin != none {
c.stdio_copy_fns << fn [mut c] () ! {
@@ -230,17 +262,21 @@ pub fn (mut c Command) start() !int {
// Prepare and start child process.
path := look_path(c.path)!
printdbg('${@METHOD}: executable found: ${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
path: path
argv: c.args
env: if c.env.len == 0 { os.environ() } else { c.env }
dir: c.dir
post_fork: [parent_pipes_hook]
pre_exec: pre_exec_hooks
}
pid := c.process.start()!
mut pid := -1
if c.process != none {
pid = c.process.start()!
}
// Start I/O copy callbacks.
if c.stdio_copy_fns.len > 0 {
@@ -252,21 +288,47 @@ pub fn (mut c Command) start() !int {
}
}
// TODO: Handle context here...
if c.ctx != none {
printdbg('${@METHOD}: start watching for context')
go c.ctx_watch()
}
return pid
}
fn (mut c Command) ctx_watch() {
mut ch := chan int{}
if c.ctx != none {
ch = c.ctx.done()
}
for {
select {
_ := <-ch {
printdbg('${@METHOD}: context is canceled/done')
if c.cancel != none {
printdbg('${@METHOD}: cancel command now!')
c.cancel() or { eprintln('error canceling command: ${err}') }
printdbg('${@METHOD}: command canceled!')
}
return
}
}
}
}
// 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) {
if c.process == none {
return error('runcmd: wait for non-started process')
} else if c.state != ProcessState{} {
return error('runcmd: wait already called')
}
c.state = c.process.wait()!
if c.process != none {
c.state = c.process.wait()!
}
unsafe { c.release()! }
}
@@ -289,8 +351,8 @@ pub fn (mut c Command) release() ! {
}
// 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.
// input stream of the child process. This descriptor is write-only for
// the parent process.
pub fn (c Command) stdin() !WriteFd {
return if c.stdio[0] != -1 {
WriteFd{c.stdio[0]}

View File

@@ -0,0 +1,35 @@
import context
import runcmd
import time
fn main() {
// Create context with cancel.
mut bg := context.background()
mut ctx, cancel := context.with_cancel(mut bg)
// Create new command with context.
mut cmd := runcmd.with_context(ctx, 'sleep', '120')
// Start a command.
println('Start command!')
cmd.start()!
// Sleep a bit for demonstration.
time.sleep(1 * time.second)
// Cancel command.
//
// In a real application, cancel() might be initiated by the user.
// For example, a command might take too long to execute and need
// to be canceled.
//
// See also command_with_timeout.v example.
println('Cancel command!')
cancel()
// Wait for command.
cmd.wait()!
// Since command has been terminated, the state would be: `signal: 15 (SIGTERM)`
println('Child state: ${cmd.state}')
}

View File

@@ -0,0 +1,46 @@
import context
import runcmd
import time
fn main() {
// Create context with cancel.
mut bg := context.background()
mut ctx, cancel := context.with_cancel(mut bg)
// Create new command as usual.
mut cmd := runcmd.new('sleep', '120')
// Set the context...
cmd.ctx = ctx
// ...and custom command cancel function.
cmd.cancel = fn [mut cmd] () ! {
if cmd.process != none {
println('Killing ${cmd.process.pid()}!')
cmd.process.kill()!
}
}
// Start a command.
println('Start command!')
cmd.start()!
// Sleep a bit for demonstration.
time.sleep(1 * time.second)
// Cancel command.
//
// In a real application, cancel() might be initiated by the user.
// For example, a command might take too long to execute and need
// to be canceled.
//
// See also command_with_timeout.v example.
println('Cancel command!')
cancel()
// Wait for command.
cmd.wait()!
// Since command has been killed, the state would be: `signal: 9 (SIGKILL)`
println('Child state: ${cmd.state}')
}

View File

@@ -0,0 +1,68 @@
/*
Note: chroot() call requires privilege escalation in the operating system.
Therefore, to run this example, run: `sudo v run command_with_chroot.c.v`
*/
import os
import runcmd
import term
fn C.chroot(&char) i32
fn main() {
// Create new root filesystem for demonstration.
new_root := '/tmp/new_root'
// Create dirtree and copy `ls` utility with shared objects...
paths := {
0: os.join_path(new_root, 'usr', 'bin')
1: os.join_path(new_root, 'usr', 'lib')
2: os.join_path(new_root, 'lib64')
}
for _, path in paths {
os.mkdir_all(path)!
}
os.cp('/usr/bin/ls', paths[0])!
os.cp('/usr/lib/libcap.so.2', paths[1])!
os.cp('/usr/lib/libc.so.6', paths[1])!
os.cp('/lib64/ld-linux-x86-64.so.2', paths[2])!
// Create a test file in the new root.
os.write_file(os.join_path_single(new_root, 'HELLO_FROM_CHROOT'), 'TEST')!
// Cleanup demo root filesystem at exit.
defer {
os.rmdir_all(new_root) or {}
}
// Prepare the command.
mut cmd := runcmd.new('ls', '-alFh', '/')
// Add pre-exec hook to perform chroot().
cmd.pre_exec_hooks << fn [new_root] (mut p runcmd.Process) ! {
if C.chroot(&char(new_root.str)) == -1 {
return os.last_error()
}
}
// Run command and read its output.
out := cmd.output() or {
if err is runcmd.ExitError {
eprintln(err)
exit(err.code())
} else {
panic(err)
}
}
// Expected output:
//
// total 4.0K
// drwxr-xr-x 4 0 0 100 Jan 8 06:39 ./
// drwxr-xr-x 4 0 0 100 Jan 8 06:39 ../
// -rw-r--r-- 1 0 0 4 Jan 8 06:39 HELLO_FROM_CHROOT
// drwxr-xr-x 2 0 0 60 Jan 8 06:39 lib64/
// drwxr-xr-x 4 0 0 80 Jan 8 06:39 usr/
println('Command output: ${term.yellow(out)}')
println('Child state: ${cmd.state}')
}

View File

@@ -0,0 +1,28 @@
import context
import runcmd
import time
fn main() {
// Create context with cancel.
mut bg := context.background()
mut ctx, _ := context.with_timeout(mut bg, 10 * time.second)
// Create new command with context.
mut cmd := runcmd.with_context(ctx, 'sleep', '120')
// Start a command.
cmd.start()!
started := time.now()
println('Command started at ${started}')
// Wait for command.
cmd.wait()!
// The `sleep 120` command would run for two minutes without a timeout.
// But in this example, it will time out after 10 seconds.
finished := time.now()
println('Command finished at ${finished} after ${finished - started}')
// Since command has been terminated, the state would be: `signal: 15 (SIGTERM)`
println('Child state: ${cmd.state}')
}

View File

@@ -1,4 +1,3 @@
import io.string_reader
import strings
import runcmd
@@ -6,12 +5,11 @@ 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 reader := runcmd.buffer(input.bytes())
mut writer := strings.new_builder(4096)
// Prepare the command.

View File

@@ -2,7 +2,7 @@ module runcmd
import os
pub type ProcCallbackFn = fn (mut p Process) !
pub type ProcessHookFn = fn (mut p Process) !
pub struct Process {
pub:
@@ -18,14 +18,13 @@ pub:
// 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) ! {}
// The pre_* and post_* fields store the functions that will be executed, respectively:
// - before fork() call in the parent process;
// - after fork() call in the parent process;
// - after fork() call and before execve() call in the child process.
pre_fork []ProcessHookFn
post_fork []ProcessHookFn
pre_exec []ProcessHookFn
mut:
pid int = -1
}
@@ -98,34 +97,57 @@ pub fn (s ProcessState) str() string {
// [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)!
if p.pid != -1 {
return error('runcmd: process already started')
}
printdbg('${@METHOD}: executing pre-fork hooks (parent)')
for hook in p.pre_fork {
hook(mut p)!
}
pid := os.fork()
p.pid = pid
printdbg('${@METHOD}: pid after fork() = ${pid}')
if pid == -1 {
return os.last_error()
}
printdbg('${@METHOD}: child pid = ${pid}')
if pid != 0 {
//
// This is the parent process after the fork
// This is the parent process
//
printdbg('${@METHOD}: executing post-fork parent callback')
p.post_fork_parent_cb(mut p)!
return pid
printdbg('${@METHOD}: executing post-fork hooks (parent)')
for hook in p.post_fork {
hook(mut p)!
}
} else {
//
// This is the child process
//
printdbg('${@METHOD}: executing pre-exec hooks (child)')
for hook in p.pre_exec {
hook(mut p)!
}
mut env := []string{}
for k, v in p.env {
env << k + '=' + v
}
// If the working directory change was performed in a pre-exec hook,
// then calling os.chdir() again should be avoided. So current working
// directory could be checked here.
if p.dir != '' && os.getwd() != p.dir {
os.chdir(p.dir)!
}
os.execve(p.path, p.argv, env)!
}
//
// 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
}
@@ -154,7 +176,7 @@ pub fn (p &Process) signal(sig os.Signal) ! {
}
}
// kill send SIGKILL to the child process.
// kill sends SIGKILL to the child process.
pub fn (p &Process) kill() ! {
p.signal(.kill)!
}

View File

@@ -1,8 +1,9 @@
module runcmd
import context
import os
// new creates new Command instance with given command name and arguments.
// new creates new command with given command name and arguments.
pub fn new(name string, arg ...string) &Command {
return &Command{
path: name
@@ -10,6 +11,18 @@ pub fn new(name string, arg ...string) &Command {
}
}
// with_context creates new command with context, command name and arguments.
pub fn with_context(ctx context.Context, name string, arg ...string) &Command {
mut cmd := new(name, ...arg)
cmd.ctx = ctx
cmd.cancel = fn [mut cmd] () ! {
if cmd.process != none {
cmd.process.signal(.term)!
}
}
return cmd
}
// 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
@@ -20,9 +33,10 @@ pub fn is_present(cmd string) bool {
}
// 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).
// name or filepath (relative or absolute). The command is searched in PATH. If
// the name contains a slash, then the PATH search is not performed, instead the
// path will be resolved and the file existence will be checked. In both cases
// the file must have the execute permission bit enabled.
// 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.

3
v.mod
View File

@@ -1,7 +1,8 @@
Module {
name: 'runcmd'
description: 'Run external commands'
version: '0.1.0'
version: '0.3.0'
license: 'Unlicense'
repo_url: 'https://github.com/gechandesu/runcmd'
dependencies: []
}