A set of small command-wrapper utilities that fill the gaps left by:
Those three ship the atoms of a Unix shell (flock, timeout, xargs -P, stdbuf, ts, chronic) but stop short of
a handful of ergonomic wrappers that show up in every non-trivial script: output-serialized parallel workers, retry
loops with policy, output caching, per-line labeling, side-effect rate limiting. Every tool here follows the same
grammar, exit-code convention, and composition rules so they stack against each other and against the classics.
Run <util> --help for the full flag reference; this README stays short and points at each tool's embedded help.
spew- Serialize STDOUT across parallel workers. Buffers each child's STDOUT and emits atomically underflockso writers to the same destination never interleavememoize- Execute a command and cache its STDOUT under a TTL. Subsequent invocations with the same arguments return the cached output without re-running the commandretry- Re-run a failing command until it succeeds or a limit is reached. Fixed, linear, exponential, or Fibonacci backoff; per-attempt and total-wall-clock timeouts; STDIN buffered and replayed to each attempt;-c 0inverts the loop into flake detection (retry-while-succeeding)prefix- Prepend a per-line label to a command's STDOUT (optionally STDERR). Defaults to a timestamp; templates support timestamp, PID, sequence-counter tokens; stream-distinguishing markers; streams line-by-line without bufferingratelimit- Bound how often a command may run. State kept as an mtime-only file per label; three modes for the cooldown case:-wwait (default),-sskip,-ffail. Complementsmemoize: memoize caches output, ratelimit gates side-effect commandswatchdog- Kill a command if it stalls (no output for a period). Complementstimeout(1): timeout caps total wall-clock, watchdog fires on lack of progress. Signal escalation (-s TERM,INT -s KILLetc.) with--gracebetween each; whole-process-group signaling;-ecounts STDERR activity tooonce- Serialize a command across all callers sharing the same label. Ergonomic wrapper aroundflock(2):-wwait (default),-sskip,-ffail. Distinct fromspew:oncegates COMMAND EXECUTION,spewgates STDOUT WRITES
Everything in this repo obeys three rules so the tools compose without surprises:
-
Same grammar.
<tool> [OPTIONS] [--] COMMAND [COMMAND ARGS...]. GNU-style flags (long+short,-abccombining,--terminator). Flag parsing stops at the first positional, so a wrapped command's flags pass through untouched (spew jq -Srunsjq -S) -
Uniform exit codes (coreutils
timeout(1)convention):<child exit>propagated,124wrapper timeout,125wrapper error,126cannot invoke,127not found.0-123stays reserved for the wrapped command -
Composable with itself and each other. Any tool can wrap any other. Four-way stack that actually runs:
spew memoize -t 5m retry -n 3 prefix -- curl -fsSL https://httpbin.org/uuid
Every tool buffers or streams appropriately so nesting stays parallel where you expect and serial where you expect
Install one utility:
go install github.com/andrew-grechkin/exec-utils/cmd/spew@latest
go install github.com/andrew-grechkin/exec-utils/cmd/memoize@latest
go install github.com/andrew-grechkin/exec-utils/cmd/retry@latest
go install github.com/andrew-grechkin/exec-utils/cmd/prefix@latest
go install github.com/andrew-grechkin/exec-utils/cmd/ratelimit@latest
go install github.com/andrew-grechkin/exec-utils/cmd/watchdog@latest
go install github.com/andrew-grechkin/exec-utils/cmd/once@latestInstall every utility in this repo:
go install github.com/andrew-grechkin/exec-utils/cmd/...@latestUsing mise
mise use go:github.com/andrew-grechkin/exec-utils/cmd/spew@latest
mise use go:github.com/andrew-grechkin/exec-utils/cmd/memoize@latest
mise use go:github.com/andrew-grechkin/exec-utils/cmd/retry@latest
mise use go:github.com/andrew-grechkin/exec-utils/cmd/prefix@latest
mise use go:github.com/andrew-grechkin/exec-utils/cmd/ratelimit@latest
mise use go:github.com/andrew-grechkin/exec-utils/cmd/watchdog@latest
mise use go:github.com/andrew-grechkin/exec-utils/cmd/once@latestSingle-tool examples live in each utility's --help. Below are copy-paste-runnable patterns that showcase what happens
when the tools stack. Everything uses standard Linux tools plus httpbin.org where a network endpoint is needed.
8 URLs to fetch. Each request may be flaky (retry with 2x exponential backoff + jitter). Successful responses cached for 5 minutes. Output atomic per worker, no interleave. Second run of the same block is instant from cache.
seq 1 8 | xargs -P0 -rn1 bash -c '
spew memoize -t 5m -- \
retry -n 3 -b 2 -j 0.2 -- \
curl -fsSL "https://httpbin.org/anything?w=$0"
'Inside-out: curl fetches → retry retries transient failures → memoize caches the success for 5 min → spew emits
each response as one atomic block.
Loop while the command keeps exiting 0, stop on the first non-zero exit and return that code. Timestamps every line so you can see whether a run was slow. Synthetic 1-in-5 failure rate for demo:
retry -n 100 -c 0 -d 100ms -- prefix -- bash -c '
n=$((RANDOM % 5))
echo "roll: $n"
test $n -ne 0
'-c 0 inverts retry's loop: retry while exit=0. Real use: swap the bash -c block for go test -run FlakyThing to
reproduce a real flake.
Wrap a POST that must fire at most once per hour per fingerprint, but survive transient failures with 3 retries inside the hour window. Add this line to cron and it's minute-triggered but hour-throttled:
ratelimit -s -i 1h -l demo-alert -- \
retry -n 3 -d 500ms -b 2 -- \
curl -fsSL -o /dev/null -w '%{http_code}\n' -XPOST -d '{"event":"disk-full"}' https://httpbin.org/postratelimit -s silently skips if a call landed in the last hour, so a * * * * * cron entry is cheap.
4 workers each doing an HTTP call. Every line prefixed with [w<id>] <ISO timestamp>; each worker's block arrives
contiguous (spew wraps prefix wraps curl):
seq 1 4 | xargs -P0 -rn1 bash -c '
spew prefix -p "[w$0] %T" -- \
curl -sSL "https://httpbin.org/anything?w=$0"
'The producer command populates the cache once; consumers using the same args get instant results and cannot fall back to
recomputing if the cache is stale (-c mode fails loud instead):
# Prime (runs the find):
memoize -t 24h -l headers -- find /usr/include -maxdepth 3 -name '*.h' | wc -l
# Later consumer with identical args: served from cache, find NEVER runs:
memoize -c -t 24h -l headers -- find /usr/include -maxdepth 3 -name '*.h' | wc -lIf the cache is missing or stale, the -c consumer exits 125 with memoize: cache is not found instead of silently
recomputing.
Attempt every 500ms for up to 3 seconds. On success exit 0; on wall-clock exhaustion exit 124 (ExitTimeout). Demo target is an unopened local port so the timeout path is easy to observe:
retry -T 3s -d 500ms -- curl -fsSL -m 1 http://localhost:9999Real use: replace localhost:9999 with a real health endpoint.
Four workers, each producing lines. Without spew the output can interleave; with spew each worker's lines stay contiguous:
# Without spew (may interleave under load):
seq 1 4 | xargs -P0 -rn1 bash -c 'seq 1 50 | sed "s/^/w$0-/"'
# With spew (blocks stay atomic):
seq 1 4 | xargs -P0 -rn1 bash -c 'seq 1 50 | spew sed "s/^/w$0-/"'Compared to per-worker temp files + concatenation, or hand-rolled flock around a subshell, or piping to ts | uniq,
spew is the direct primitive.
10 workers, all sharing a single label. ratelimit -w queues them so consecutive calls stay at least 200ms apart even
though xargs -P0 starts them simultaneously. Useful for a courteous batch against a rate-limited API:
seq 1 10 | xargs -P0 -rn1 bash -c '
ratelimit -w -i 200ms -l api-batch -- \
curl -fsSL "https://httpbin.org/uuid"
'Peer workers block on the same flock; each releases after touching mtime, so the effective throttle is exactly one call per 200ms across the fleet.
retry -t DUR kills an individual attempt if it exceeds DUR (like a stall detector inside one retry cycle);
retry -T DUR caps the whole loop. They compose:
# httpbin sleeps 2s per call. -t 500ms kills each attempt fast; -n 3 stops after 3 kills.
retry -n 3 -t 500ms -d 100ms -- curl -fsSL -m 5 https://httpbin.org/delay/2Wall time is ~1.7s (3 attempts x 500ms + 2 x 100ms sleep) not 6s. When -t fires, retry walks the -s escalation on
the child's process group (default TERM,KILL, with min(-t/2, 5s) between steps) so a well-behaved child can clean up
before SIGKILL. Signals share names and semantics with watchdog -s; a bare retry -t DUR -- cmd needs no signal flag.
Final exit follows the shell convention 128 + signum: 143 when the child cleaned up on SIGTERM, 137 when it ignored
TERM and had to be killed.
Poll a hung endpoint but never wait longer than 500ms per attempt for output. TERM first (child gets to clean up), KILL after 200ms grace if it ignored TERM. Whole process group is signaled so shell wrappers die cleanly too.
watchdog -v -i 1s -g 2s -- bash -c 'trap "" TERM; sleep 5'bash -c 'trap "" TERM' deliberately ignores SIGTERM so you can watch the escalation reach SIGKILL in the verbose log.
Wall time is ~700ms (500ms idle + 200ms grace) not 5s.
Three parallel "compilers" each emitting STDOUT and STDERR. spew prefix -s labels each stream (STDOUT| / STDERR|)
AND keeps every worker's block atomic:
seq 1 3 | xargs -P0 -rn1 bash -c '
spew prefix -p "[b$0]" -s -- bash -c "echo compile ok; echo warn: unused var >&2"
' 2>&1 | sortSort by the stream tag to separate signal (STDOUT, real output) from noise (STDERR, warnings) after the fact, without needing to redirect at the source.
A minute-triggered cron job that must not overlap: once -s returns exit 0 immediately if a prior run is still
holding the lock, so cron doesn't stack invocations while a slow run finishes.
* * * * * once -s -l backup -- rsync -a src/ dst/Compose with ratelimit when the job should also honor a minimum interval independent of overlap:
once -s -l job -- ratelimit -s -i 6h -l job -- ./expensive-job.shonce gates execution (a second caller returns 0 if a first is running); ratelimit gates cadence (a second caller
returns 0 if the last run was within the interval). Both together: at most one running, at most one per 6h.
Invariants every utility in this repo shares. New utilities should follow them so the fleet stays legible.
- Verbose.
-v/--verboselogs to STDERR, one line per event, prefixed with<util>: - Help.
//go:embed help.txtat build;-h/--helpand no-args both write it to STDOUT - Version.
--versionwritesdebug.ReadBuildInfo().Mainas pretty JSON viacli.PrintVersion(tip:<util> --version | jq -r .Version) - State dirs.
cli.ResolveStateDir(explicit, envKey, defaultName) (string, error). Precedence:--dirflag value, then$<UTIL>_DIR, then$XDG_RUNTIME_DIR/<util>, then/tmp/<util>-<uid>. Explicit/env/XDG dirs are used verbatim; the/tmpfallback is created0700and refused if it already exists as a symlink or a dir not owned by the uid, so an unprivileged co-tenant can't pre-create it - Labels map to keyfiles.
filepath.Join(dir, cli.SHA256Hex(label)). Anonymous invocations key onargv(folded withpwdwhere the util is workdir-scoped) viacli.CommandKey, which length-prefixes every field so["a b"]and["a", "b"], or apwdcontaining a newline, never alias onto one key - Child process attrs and signals. Every wrapper spawns its child with
cli.NewChildProcAttr()(which setsSetpgid: true) AND either usescli.RunChild(cmd)or installscli.StartSignalForwarder(cmd.Process.Pid, ...)betweencmd.Start()andcmd.Wait(). This pair is not optional; using one without the other is a bug. See DESIGN DECISIONS below for the full rationale - Signal parsing.
-s/--signalusescli.ParseSignals; default isTERM,KILL;KILLis auto-appended if the user's list omits it. Names case-insensitive,SIGprefix optional - Error classification.
cli.IsWrapperError(err)decides whether the wrapper prints its own diagnostic. Wrapper-side failures (couldn't exec, permission denied) do; child outcomes (own exit code, signal death) don't (they're already visible via the exit code and the child's own STDERR) - Integration fixtures. Directory-per-fixture under
test/fixtures/<util>/<case>/. Recognised files:args,stdin,run,expected-stdout,expected-stdout-contains,expected-stderr-contains,expected-exit,description. All optional. SeeCONTRIBUTING.md
Rationale for the non-obvious choices. When extending a utility or adding a new one, understand why these were picked before deviating.
-
setpgidon the child, wrapper outside, wrapper forwards terminal signals. This is the load-bearing design choice for how every wrapper signals its subtree.Goal. When the wrapper's own timer fires (retry per-attempt timeout, watchdog stall), I want to kill the wrapped command AND anything it spawned in the background: a
bash -c 'helper &; wait', acoproc, a background pipeline. Without a pgroup,kill(childPid, sig)only reaches the direct child; any grandchild in the background is reparented to init and keeps running until it exits on its own. For example,retry -t 100ms -- bash -c 'coproc { sleep 30; }; wait'would kill bash but leave the coproc and itssleep 30running orphaned for the rest of the 30 seconds. The clean POSIX mechanism for reaching a whole subtree iskill(-pgid, sig)on a process group.Why the child leads its own pgroup, not the wrapper.
cli.NewChildProcAttr()setsSetpgid: truewith no Pgid, which creates a fresh process group wherechild.pgid == child.pid; everything the child forks inherits that pgid. The wrapper is deliberately NOT a member of that pgroup. That is what makeskill(-childpgid, SIGKILL)safe: the wrapper survives to propagate the child's exit code. The alternative configuration ("wrapper is pgroup leader, children join") was rejected because SIGKILL is uncatchable, so a wrapper inside the pgroup it's signalling would die with the children.The forwarder pays the cost of that isolation. Because the child is in a separate pgroup, terminal signals delivered to the wrapper's foreground pgroup (SIGINT from Ctrl-C, SIGHUP on ssh disconnect, SIGTERM from an external kill) no longer reach the child.
cli.StartSignalForwarderinstalls asignal.Notifyhandler for SIGINT / SIGTERM / SIGHUP that relays each signal into the child's pgroup viakill(-childpgid, sig). Retry additionally passes an*atomic.Bool"interrupted" flag so its loop stops iterating instead of spawning a fresh attempt into a shutdown.Composition.
spew -- retry -- bash -c 'cmd &': spew's child (retry) is in a new pgroup led by retry; retry's child (bash) is in another new pgroup led by bash; bash'scmd &inherits bash's pgroup. Every wrapper aggregates exactly its own subtree; wrappers do not accidentally signal each other. Terminal Ctrl-C reaches spew via foreground pgroup, spew forwards to retry, retry forwards to bash, bash's pgroup dies together -
ratelimitandonceshare plumbing but not semantics. Both usecli.LockMode(wait / skip / fail), both usecli.ResolveStateDir, both hash labels the same way. They differ in the question they ask:ratelimitgates on the state file's mtime relative to-i(cadence);oncegates on liveflockcontention (concurrent overlap). Composable:once -s -- ratelimit -s -- ...means "at most one running AND at most one per interval" -
spewlocks on STDOUT's underlying file (via/proc/self/fd/1), not a dedicated lockfile. Every writer to the same file (or terminal) converges on the same lock target automatically, no coordination needed. Fallback is the running binary path when STDOUT is a pipe / socket with no filesystem entry -
Signal-killed exit is
128 + signum(shell convention), not Go's-1sentinel. Set incli.ExitCodeForviasyscall.WaitStatus.Signaled(). This is what bash reports and what every shell script author expects -
memoizecaches STDOUT, not exit codes. Non-zero exit skips the cache write entirely (no negative caching); empty output is not committed at all, and a pre-existing zero-byte entry is treated as a miss unless-eis passed (guards against caching "silently failed" runs). Cache dirs are created0700and payloads written0600, since cached output can contain secrets. Each writer commits through a unique temp file, so concurrent misses on the same key cannot interleave or fail with a spurious 125. Sliding TTL (-s) bumps mtime on each hit, so a heavily-read cache never expires -
Retry's flake mode is
-c 0, not a dedicated flag.-c CODEmeans "retry only on these codes"; passing 0 inverts the loop (retry WHILE succeeding, stop on first failure). Same primitive, dual-use. Seejust detect-flaky-testsin the justfile for the canonical usage
Things that will trip a contributor writing their first patch.
- Fixture timing bounds must survive load. The integration harness runs every fixture concurrently under
xargs -P0. A timing-tight fixture that passes in isolation can fail catastrophically under the parallel harness. Rule of thumb: wall-clock upper bounds of 3s or more, kill-escalation grace of 500ms or more, TTL windows at least 4x the sleep between reads. Prefer readiness synchronization (child writesreadyto stdout, wrapper's pump resets its clock) over "wait N ms" - The trap-race pattern.
bash -c 'trap "" TERM; sleep N'does NOT guarantee the trap is installed before the outer wrapper's timer fires; bash's own startup under load can exceed 100 to 200ms. Usebash -c 'trap "" TERM; echo ready; sleep N'so the wrapper's stdout-watching timer only starts counting silence after bash confirms readiness. This burned two escalation fixtures; the pattern is now the standing convention memoizeon a side-effect-only command doesn't do what it looks like.memoize -- migratecaches migrate's STDOUT, not its exit code. If migrate's stdout is empty, nothing is cached and every call re-runs it. If migrate exits non-zero, nothing is cached. Useratelimitfor "run at most once per interval" oroncefor "one at a time";memoizeis for "reuse the output of an expensive read-only computation"-cin retry accumulates AND splits on commas. Both-c 0,2and-c 0 -c 2yield the same set. Same for-sin retry / watchdog. This is pflagStringSliceVarbehaviour and users may be surprised expecting one or the other exclusivelyspewbuffers the entire child STDOUT in memory before emitting. For giant outputs (multi-GB log dumps) it will hold that in RAM until the child exits. Useprefixwhen you want line-atomic streaming instead of block-atomic buffering
- Andrew Grechkin
This project is licensed under the MIT License
See the LICENSE file for details.