Run a command quietly unless it fails.
Project description
shushrun
Run a command quietly unless it fails — then surface its full output, with stdout and stderr put back on their own file descriptors and in (best-effort) original order.
Why?
No single tool does both halves of this: log every run of a command (to journald or a file) and stay silent unless it fails, replaying the captured output only on a non-zero exit. chronic gives the silence but keeps no log; systemd-cat and tee log but always print. shushrun is the combination.
The problem
cron mails a job's output whenever it writes to stdout/stderr or exits non-zero. That's useful, but a job that prints harmless warnings to stderr on every run produces mail on every run. Capturing the warnings to silence them means that on a failure cron reports only that the job failed — the output then needed has already been discarded.
The naive fix — output="$(cmd 2>&1)"; [ $? -ne 0 ] && echo "$output" — loses the distinction between stdout and stderr, because merging the two streams is the one thing that destroys it.
shushrun captures both streams, always logs them, and on a non-zero exit replays stdout to stdout and stderr to stderr (in capture order) before exiting with the command's own code — so cron stays silent on success and mails the full, faithful picture on failure.
Behaviour
- Always: record the command's output — to journald by default (tagged with the command's basename; stdout at info, stderr at warning, plus an exit-status entry at err on failure / info on success), or to a file with
--log FILE(plain lines;--log-emojiprefixes🗒stdout /⚠stderr, or set custom prefixes with--log-stdout-prefix/--log-stderr-prefix). - Success (exit 0): emit nothing — cron stays quiet even if the command printed warnings.
- Failure (exit ≠ 0): replay stdout on stdout and stderr on stderr, in order, then exit with the command's exit code.
Installation
shushrun is a single-file, dependency-free tool on PyPI. Run it without installing via uv:
uvx shushrun -- mycmd --flags
or install the shushrun command with pipx install shushrun, uv tool install shushrun, or pip install shushrun. It needs only Python ≥ 3.9 and, for the default journald sink, a systemd host — no compiler, no C-extension wheel, nothing to build.
Usage
shushrun [--reader poll|threads] [--log FILE] -- mycmd --flags
journald is the default sink, so dropping it in front of an existing cronjob needs no flags:
MAILTO=admin@example.com
17 3 * * * shushrun -- /usr/local/bin/myjob.sh
Every run is logged to journald (journalctl -t myjob.sh); cron mails only when myjob.sh exits non-zero. Pass --log FILE (or set SHUSHRUN_LOG) to write a file instead — there is no default file. The default journald sink needs no dependency (shushrun writes journald's native socket directly); it prefers the python3-systemd or cysystemd binding when one is installed, and shushrun --help shows which sink is in use. See Design decisions.
Environment variables
Every option has an environment-variable equivalent, named SHUSHRUN_<OPTION> — set a default once (in a cron file, a systemd unit's Environment=, or a shell profile) instead of repeating flags on every invocation. An explicit command-line flag takes precedence over its variable.
| Variable | Option | Example value |
|---|---|---|
SHUSHRUN_READER |
--reader |
poll or threads |
SHUSHRUN_LOG |
--log |
/var/log/myjob.log |
SHUSHRUN_LOG_EMOJI |
--log-emoji |
1 (also true / yes / on) |
SHUSHRUN_LOG_STDOUT_PREFIX |
--log-stdout-prefix |
O> |
SHUSHRUN_LOG_STDERR_PREFIX |
--log-stderr-prefix |
E> |
SHUSHRUN_STATS |
--stats |
1 |
Example
demo — a job that prints progress to stdout, a warning to stderr, and exits with the code it is given:
#!/bin/sh
echo "syncing 42 records"
echo "warning: connection retried once" >&2
echo "sync complete"
exit "${1:-0}"
Success is silent — a stderr warning alone produces no output, so cron stays quiet:
$ shushrun --log demo.log --log-emoji -- ./demo 0
$ echo $?
0
Failure replays both streams and re-exits with the command's code:
$ shushrun --log demo.log --log-emoji -- ./demo 1
syncing 42 records
sync complete
warning: connection retried once
./demo 1: exited with status 1
$ echo $?
1
(The streams come out grouped rather than interleaved: this three-line job finishes faster than the reader can interleave them — the best-effort ordering discussed below. Within each stream the order is always exact.)
Both runs are recorded — to journald by default, or to a file with --log. With --log-emoji (opt-in, used here) stdout lines are marked 🗒 and stderr ⚠:
$ cat demo.log
# 2026-06-16T10:27:15+07:00 exit 0 cmd: ./demo 0
🗒 syncing 42 records
🗒 sync complete
⚠ warning: connection retried once
# 2026-06-16T10:27:15+07:00 exit 1 cmd: ./demo 1
🗒 syncing 42 records
🗒 sync complete
⚠ warning: connection retried once
stdbuf matters
A C/libc program block-buffers stdout (4 KB) when it's a pipe but leaves stderr unbuffered, so stderr races ahead and the captured order is wrong before shushrun ever sees it. Prepend stdbuf --output=L --error=L to line-buffer the source. It's best-effort: no effect on Go, static binaries, or programs that set their own buffering.
The readers, and a concurrency note
--reader poll (default) is a single thread running select.poll() over both pipes. --reader threads runs one thread per stream. They produce equivalent results for any normal (non-CPU-bound) producer.
There is no meaningful GIL vs free-threaded difference here. A blocking read releases the GIL in CPython, so two reader threads already run their reads concurrently on a stock build; free-threading only parallelizes the nanosecond-scale Python work (append under a lock), which is never the bottleneck. This is an I/O-bound problem, and the GIL already steps out of the way for I/O. The single poll loop is simpler, has no lock, and is the one to use.
--reader threads --stats makes this observable: it prints the live gil state and the captured line count; between a stock python3 and a free-threaded python3.14t the gil flag flips True/False while the captured order barely moves.
The fundamental limit: order vs distinction
Exact cross-stream order and the stdout/stderr distinction cannot coexist. Once the command's two write() calls land in two separate kernel pipe buffers, nothing records which happened first — a reader cannot recover it, no matter how many CPUs it has. The only way to preserve exact order is to merge into one stream (2>&1), which is exactly what discards the distinction.
So shushrun keeps the distinction and accepts best-effort cross-stream order (exact within each stream, always). For a normal logger that's effectively perfect; it only clumps under sustained, simultaneous high-rate output on both streams.
Similar tools
shushrun sits in a well-trodden space; if it isn't the right fit, one of these likely is. Install lines are Debian/Ubuntu.
chronic — apt install moreutils
The closest match: run a command, show its output only if it fails, keeping stdout/stderr on their own fds. It does not interleave (it buffers each stream and prints stdout then stderr) and keeps no log. For quiet-unless-failed alone, with no log needed, this is the first choice — it's tiny and packaged everywhere.
$ chronic ./demo 0 # exit 0 (even with a stderr warning) -> silent
$ chronic ./demo 1 # failure -> dump, exit 1
syncing 42 records
sync complete
warning: connection retried once
cronic — apt install cronic
cronic — a classic shell script for taming cron spam. It treats a non-zero exit or any stderr as failure and prints a sectioned report. Policy difference worth noting: a stderr warning on an otherwise-successful run does trigger output, where shushrun and chronic stay silent.
$ cronic ./demo 0 # exit 0 but stderr present -> reported anyway
Cronic detected failure or error output for the command:
./demo 0
RESULT CODE: 0
ERROR OUTPUT:
warning: connection retried once
STANDARD OUTPUT:
syncing 42 records
sync complete
annotate-output — apt install devscripts
annotate-output(1) — not quiet-unless-failed; it always prints, prefixing every line with a timestamp and O:/E: so the streams stay distinguishable in one merged stream, in arrival order. Complementary to shushrun, and subject to the same best-effort ordering (here it happened to read stderr first):
$ annotate-output ./demo 0
10:27:15 I: Started ./demo 0
10:27:15 E: warning: connection retried once
10:27:15 O: syncing 42 records
10:27:15 O: sync complete
10:27:15 I: Finished with exitcode 0
ts (also apt install moreutils) timestamps arbitrary lines and pairs well with these. In the systemd world, a timer unit with OnFailure= plus journalctl is the non-wrapper alternative: journald records each line's stream and order, with alerting driven from the OnFailure handler.
Tests
tests/test_shushrun.py (pytest) is two tiers:
- Hard invariants (gate the build): silence on success, split replay on failure, exit-code propagation, missing-command → 127, and the deterministic ordering guarantee — within-stream order is exact and no line is lost — each run against both readers.
- Measurements (do not gate): cross-stream
max_run/ mean displacement. These are scheduling-dependent with no fixed correct value, so they arexfail(strict=False)and recorded viarecord_property— they run and report their real numbers, but neither outcome fails the suite. (xfailis isolated to the flaky metric so it can't mask a real regression in the hard tier.)
pytest # green; flaky measurements never break it
pytest -rxX -s -k interleave # see the recorded numbers and XFAIL/XPASS
uv run --with pytest --python 3.13 pytest # GIL build
uv run --with pytest --python 3.14t pytest # free-threaded; gil flips, suite stays green
SHUSHRUN_BIN=/usr/local/bin/shushrun pytest # point at an installed binary
Visualizing the reordering
tests/visualize_ordering.py runs the producer (whose sent order is known to be strict o,e,o,e,…) through shushrun and prints the sent (TX) and received (RX) sequences as aligned, terminal-width-wrapped line pairs. Out-of-place characters are shown in UPPERCASE (lowercase = correct place), so the marking survives copy-paste; with colour on they also get a red background.
python3 tests/visualize_ordering.py --reader threads --delay-us 1000 # best-effort: scattered swaps
python3 tests/visualize_ordering.py --reader threads --delay-us 0 # bursty: order collapses
python3 tests/visualize_ordering.py --reader poll --delay-us 1000 # single reader: exact here
Design decisions
Why things are built this way — so a future reader can tell a deliberate choice from an accident, and knows what would justify changing it. Each entry has a Revisit if: when that becomes true, change the decision rather than carrying it forward unexamined.
journald sinks: native socket by default, bindings optional
record_to_journald logs to journald through the best of three sinks, tried in order, using the first available. shushrun --help prints the list, marks the one chosen, and gives the reason any are not available:
systemd.journal— the canonical binding: thepython3-systemddistro package (apt install python3-systemd) or thesystemd-pythonPyPI sdist (source-only — needslibsystemd-dev+ a C compiler). Theshushrun[systemd]extra pulls the latter.cysystemd— a separate PyPI implementation with the samejournal.send(MESSAGE=…, PRIORITY=…, SYSLOG_IDENTIFIER=…)API that ships prebuilt wheels, sopip/uvinstall it with no toolchain. Theshushrun[cysystemd]extra pulls it.- The native socket (stdlib) — with no binding installed, shushrun writes journald's submission protocol directly to
/run/systemd/journal/socket: framedKEY=VALUEdatagrams, length-prefixed for any value containing a newline. This is the default and needs nothing — no dependency, no wheel, no compiler — souvx shushrun/pip install shushrunworks on any interpreter. That is why the bindings are optional extras, not hard requirements.AF_UNIXdatagram sends are reliable (they block rather than drop when journald is busy).
Bindings rank first because they are maintained and handle one case the socket writer does not — a single message larger than the socket buffer, which a binding sends via a sealed memfd. The socket is the zero-dependency floor that makes the tool installable anywhere journald runs.
Revisit if: a single log line can exceed the datagram limit in practice — add the memfd path to the socket writer, or document installing a binding. If systemd-python starts shipping wheels, fold it and cysystemd into one recommended extra.
journald is the default destination (file is opt-in)
With no flags, shushrun -- cmd logs to journald; --log FILE (or SHUSHRUN_LOG) writes a file instead. There is intentionally no "log nowhere" default. The point is to drop in front of an existing command with zero ceremony, and journald is the right default on a systemd host: structured, queryable (journalctl -t <cmd>), and it carries the stream in the entry priority for free.
Revisit if: it's often run on non-systemd hosts and the missing-binding warning becomes noise — then auto-detect journald and fall back to silence, or make the default configurable.
No default log file path
--log has no built-in default; only an explicit --log or SHUSHRUN_LOG produces a file. An earlier version defaulted to ~/shushrun.log, which scatters stray, never-rotated files wherever the tool runs, under whatever user invoked it. Opt-in only.
Revisit if: never, probably — a surprise file per host has no upside.
File prefixes: plain by default, emoji via --log-emoji, or custom
--log files are plain by default; with --log-emoji, stdout lines are prefixed 🗒 and stderr ⚠ (journald distinguishes streams by PRIORITY regardless). Off by default because per-line emoji are a matter of taste — opinionated output shouldn't be forced on everyone. The emoji are only defaults — --log-stdout-prefix STR / --log-stderr-prefix STR override either prefix (used verbatim; pass '' to suppress one), so anyone who prefers ASCII (O: / E: ) or other glyphs sets them without touching the code. As for the prefix glyph itself: a [stderr] text prefix is verbose and marks only one stream. A hidden / zero-width per-line prefix was considered and rejected: invisible marking defeats the prefix's only purpose (a human can't see the stream), and zero-width bytes silently break grep, wc, copy-paste, and editors that strip "weird" characters. The prefixes are bare codepoints (U+1F5D2, U+26A0); append U+FE0F to force ⚠ to emoji width so the two columns align.
Revisit if: machine-parseable file logs are needed (use journald, or emit JSON lines). Emoji rendering badly is now a runtime fix, not a code change — pass ASCII via --log-stdout-prefix / --log-stderr-prefix.
Keep the stdout/stderr distinction; accept best-effort cross-stream order
shushrun preserves within-stream order exactly and cross-stream order best-effort. Exact order and the distinction can't coexist: once a process's two write()s land in separate kernel pipe buffers, nothing records which came first, so no reader can recover it at any CPU count; merging (2>&1) restores exact order but destroys the distinction.
Revisit if: a consumer needs exact order more than the split — merge the streams with 2>&1 (losing the distinction) rather than changing this.
Default reader is the single poll loop; threads is a teaching aid
--reader poll (one thread, select.poll) is the default; --reader threads exists but is documented as a lesson, not a speedup. This is I/O-bound, and in CPython a blocking read releases the GIL, so two reader threads already read concurrently on a stock build — and a free-threaded build only parallelizes the nanosecond-scale append. Measured: GIL vs free-threaded changes nothing here. poll is also simpler (no lock, no shared state) and deterministic.
Revisit if: the reader ever becomes CPU-bound (it won't for line-tagging).
stdbuf is the caller's job, not baked in
The tool doesn't auto-prepend stdbuf --output=L --error=L; it's documented for the caller to add when cross-stream order matters. stdbuf only affects libc stdio buffering — no effect on Go, static binaries, or programs that set their own — and it silently changes the child's behavior, so baking in a best-effort, sometimes-no-op change would be surprising.
Revisit if: only libc programs are ever wrapped and automatic line-buffering would be preferred.
The tool runs the command; it is not a pipe filter
Usage is shushrun -- cmd, not cmd | shushrun. A pipe gives the downstream tool only stdout (stderr bypasses it), and a shell pipeline's exit status is the last command's — so stderr would never reach journald or the mail, and "mail only on non-zero exit" could never fire. Running the command is the only way to capture both streams and propagate the real exit code.
Revisit if: never — this is structural, not a preference.
Capture fully, then record/replay after the command exits
Output is buffered per line in memory; journald/file writes and the failure replay happen after the command exits. This decouples logging from the job — a journald restart, a full disk, or a slow sink can't break, block, or SIGPIPE the wrapped command (a real risk with cmd | tee | systemd-cat). The cost: a run's journald entries appear at completion, not live.
Revisit if: a long job needs live progress — then stream through systemd-cat, accepting the coupling and SIGPIPE caveat.
Tests: invariants gate the build; ordering measurements don't
The pytest suite hard-asserts the deterministic guarantees (silence on success, split replay, exit-code propagation, within-stream order, no lines lost). Cross-stream ordering numbers are xfail(strict=False) and recorded, never gating — they're scheduling-dependent with no fixed correct value, so asserting a specific number would be flaky and would dress a best-effort property up as a guarantee. The xfail is isolated to the measurement so it can't mask a regression in the invariants.
Revisit if: an ordering property becomes deterministic (it won't, short of merging the streams).
TODO
- Don't break silence-on-success when journald is unreachable. With no binding installed and no journald socket (a non-systemd host), the native-socket write fails and shushrun warns to stderr on every run — which a successful cron job would then mail. Detect an absent
/run/systemd/journal/socketand fall back to a file, or to silence, instead of warning. - Bound memory on huge output. The whole run is buffered in memory before it is recorded and replayed — fine for normal logs, but a job that emits gigabytes uses that much RAM. Optionally spill to a temp file past a size threshold. Related: a single buffered line larger than the journald socket's datagram limit is dropped by the socket sink — the sealed-memfd path a binding provides would carry it.
Releasing
shushrun publishes to PyPI from GitLab CI via Trusted Publishing (OIDC): no API token is stored anywhere — PyPI mints a one-shot upload token from a short-lived GitLab id_token. To cut a release, bump version in pyproject.toml, commit, and push a matching tag:
git tag v1.0.1 && git push origin v1.0.1
The tag pipeline (.gitlab-ci.yml) builds the sdist + wheel and uploads them. The trusted publisher is registered on PyPI for namespace paulto, project shushrun, pipeline .gitlab-ci.yml, environment pypi.
License
GNU Affero General Public License v3.0 or later. Copyright (C) 2026 Paul Tobias. The Affero clause is the point: anyone who runs a modified shushrun as a network service must offer its source to those users — copyleft the SaaS loophole can't route around.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file shushrun-1.0.0.tar.gz.
File metadata
- Download URL: shushrun-1.0.0.tar.gz
- Upload date:
- Size: 31.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3bf40ee6879a817a7830e3c54a9108adc92623774432ee61c84a4f60d77fa721
|
|
| MD5 |
ba108b9ef4717bf4469b4e8e2d6b48e9
|
|
| BLAKE2b-256 |
385234769f6a5c14c8e603e5a6e611184844884297cb3334e3e2e7d9b5687db6
|
File details
Details for the file shushrun-1.0.0-py3-none-any.whl.
File metadata
- Download URL: shushrun-1.0.0-py3-none-any.whl
- Upload date:
- Size: 26.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6e0edfd8d70f60b8a35a236698ca296797f247eee89dfa3da48f5aa2b92d859e
|
|
| MD5 |
badfaf392d3ab3018763f6109961bebe
|
|
| BLAKE2b-256 |
5b7df75d204219bfbae46cad90beb8a959c4007b6730c70d1f15546e6df37447
|