Metadata-Version: 2.4
Name: shushrun
Version: 1.0.0
Summary: Run a command quietly unless it fails.
Project-URL: Homepage, https://gitlab.com/paulto/shushrun
Project-URL: Repository, https://gitlab.com/paulto/shushrun
Project-URL: Issues, https://gitlab.com/paulto/shushrun/-/issues
Author-email: Paul Tobias <gitlab@tobias.pt>
License-Expression: AGPL-3.0-or-later
License-File: LICENSE
Keywords: cron,journald,logging,stderr,systemd,wrapper
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: System :: Logging
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Utilities
Requires-Python: >=3.9
Provides-Extra: cysystemd
Requires-Dist: cysystemd; extra == 'cysystemd'
Provides-Extra: systemd
Requires-Dist: systemd-python; extra == 'systemd'
Description-Content-Type: text/markdown

# 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-emoji` prefixes `🗒` 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](https://docs.astral.sh/uv/):

```sh
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

```sh
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:

```cron
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](#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:

```sh
#!/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:

```console
$ shushrun --log demo.log --log-emoji -- ./demo 0
$ echo $?
0
```

Failure replays both streams and re-exits with the command's code:

```console
$ 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 `⚠`:

```console
$ 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`

[moreutils](https://joeyh.name/code/moreutils/) · [chronic(1)](https://manpages.debian.org/chronic)

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.

```console
$ 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](https://habilis.net/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.

```console
$ 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)](https://manpages.debian.org/devscripts/annotate-output.1.html) — 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):

```console
$ 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 are `xfail(strict=False)` and recorded via `record_property` — they run and report their real numbers, but neither outcome fails the suite. (`xfail` is isolated to the flaky metric so it can't mask a real regression in the hard tier.)

```sh
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.

```sh
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:

1. **`systemd.journal`** — the canonical binding: the `python3-systemd` distro package (`apt install python3-systemd`) or the `systemd-python` PyPI sdist (source-only — needs `libsystemd-dev` + a C compiler). The `shushrun[systemd]` extra pulls the latter.
2. **`cysystemd`** — a separate PyPI implementation with the same `journal.send(MESSAGE=…, PRIORITY=…, SYSLOG_IDENTIFIER=…)` API that ships prebuilt wheels, so `pip`/`uv` install it with no toolchain. The `shushrun[cysystemd]` extra pulls it.
3. **The native socket** (stdlib) — with no binding installed, shushrun writes journald's submission protocol directly to `/run/systemd/journal/socket`: framed `KEY=VALUE` datagrams, length-prefixed for any value containing a newline. This is the default and needs nothing — no dependency, no wheel, no compiler — so `uvx shushrun` / `pip install shushrun` works on any interpreter. That is why the bindings are *optional* extras, not hard requirements. `AF_UNIX` datagram 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/socket` and 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](https://docs.pypi.org/trusted-publishers/) (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:

```sh
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](LICENSE). 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.
