6 Commits

Author SHA1 Message Date
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
9 changed files with 263 additions and 35 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}'`. Only stdout and exit_code are available in Result.
* `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...

60
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:
@@ -52,15 +55,29 @@ 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
// 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.
@@ -155,7 +172,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')
}
@@ -240,7 +257,10 @@ pub fn (mut c Command) start() !int {
post_fork_child_cb: post_fork_child_cb
}
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 +272,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')
}
if c.process != none {
c.state = c.process.wait()!
}
unsafe { c.release()! }
}

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,27 @@
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.
started := time.now()
println('Start command at ${started}')
cmd.start()!
// 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.
println('Command finished after ${time.now() - started}')
// Since command has been terminated, the state would be: `signal: 15 (SIGTERM)`
println('Child state: ${cmd.state}')
}

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

3
v.mod
View File

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