verifyfirst

A command's return status

The command exited zero, so you moved on.

What it captures

Whether the process believed it completed the operation it chose to attempt.

What it cannot see

Known failures · 16

NS-005 observedno-op-reported-as-action

enable --now does not restart an already-running unit

reads as
The command exits zero and the service is active. Conclusion drawn: the new code is live.
actually
systemctl enable --now starts a stopped unit. On a running one it is a no-op. The old process, with the old ExecStart, survives.
blind because
Exit code zero and `active (running)` are true statements about the wrong process.
the check
Compare the unit's ExecStart on disk against the live process: systemctl show -p ExecStart NAME and ps -p $MAINPID -o args=
cost of missing
A deploy is reported as complete twice while the previous binary keeps serving.
generalises to
Any idempotent-looking command whose semantics differ by current state.
NS-008 observedunrelated-precondition

set -e aborts a script at a validation step that concerns something else

reads as
The install script ran and the config file is in place. Conclusion drawn: the change is active.
actually
A validation step covering the whole configuration failed on an unrelated block that needed an environment variable the script did not load. Under set -e the script exited before the reload.
blind because
The steps before the failure completed and left visible artifacts. Partial success looks like success when only the artifacts are inspected.
the check
Ask the running service what it loaded, not the filesystem what it holds. For Caddy: the admin API's live config.
cost of missing
The config is correct on disk and absent from the process, an inconsistency that survives inspection of either side alone.
generalises to
Any pipeline where a global check gates a local change.
NS-013 observedself-terminating-action

A teardown script destroys the environment it is executing inside

reads as
Several long-running sessions vanish at once with no error output. Conclusion drawn: the tool crashed, or the machine failed.
actually
A rebuild script ran `kill-session` against the multiplexer session it was itself running in. It killed its own parent, taking four unrelated sessions with it. There is no crash and no error because the script did exactly what it was told.
blind because
A process that is killed cannot report that it was killed, and cannot report why. The absence of an error reads as an unexplained crash rather than a successful destructive command.
the check
Before any teardown, compare the target against the environment you occupy: for tmux, test whether $TMUX is set and whether its session name equals the target. Refuse if they match.
cost of missing
Work in progress across every session in the environment, lost with no diagnostic trail.
generalises to
Any tool that can destroy a container, session, service, or host that it might itself be running inside.
NS-014 observedno-signal-at-all

A privilege prompt with nowhere to appear hangs instead of failing

reads as
A deploy step produces no output and does not return. Conclusion drawn: the operation is slow, or the network is stalling.
actually
The command needed a password. There is no terminal to prompt on, so it waits indefinitely. No error, no exit code, no timeout.
blind because
Exit codes only exist for processes that exit. An instrument that reads return status has nothing at all to read, and silence resembles work in progress.
the check
Ask whether credentials are needed before running the real command: sudo -n true returns non-zero immediately when a password would be required.
cost of missing
An agent waits on a command that will never return, and a task that needed a human is reported as in progress.
mitigation
Wrap anything that might prompt in a timeout, so a hang converts into a failure you can observe.
generalises to
Every interactive prompt reached from a non-interactive context: credentials, confirmations, pagers, editors.
NS-015 documentedstatus-from-the-wrong-process

A pipeline returns the status of its last command, not its failing one

reads as
`npm test | tee build.log` exits zero and the log file is written. Conclusion drawn: the tests passed.
actually
A shell reports the exit status of the last command in a pipeline. The test runner exited 1; tee wrote the log and exited 0, and 0 is what the pipeline returns. `set -e` does not intervene, because the pipeline as a whole succeeded.
blind because
One number is produced for a chain of processes. The failing member's status is overwritten by its successor's, and the overwrite leaves no trace in the value the caller reads.
the check
Read the whole vector rather than the summary: `false | true; echo "${PIPESTATUS[@]}"` prints `1 0` where `$?` prints `0`. Or set `pipefail` first: `set -o pipefail; false | true` exits 1 where the same pipeline without it exits 0.
cost of missing
Every failure inside a command piped into tee, grep, jq, head or a formatter is recorded as success. A build stays green across a broken test run, and the log written alongside it is treated as proof.
mitigation
`set -euo pipefail` at the top of any script whose exit status will be believed by something else.
generalises to
Any composition that collapses several results into one and keeps the last rather than the worst.
source
gnu.org
NS-016 documentedtransport-success-as-semantic-success

curl exits zero after successfully downloading an error page

reads as
`curl -s -o data.json URL` exits 0 and data.json exists with content in it. Conclusion drawn: the fetch succeeded.
actually
The server answered 404 or 500. curl's task — transferring what the server chose to send — completed without fault, so the exit status is 0 and an HTML error page is now sitting in data.json under the name of the expected document.
blind because
The exit code describes the transfer, not the response. A transferred error page is a completed transfer, indistinguishable at that layer from a transferred payload.
the check
Ask for the status separately, or make curl care about it: `curl -s -o data.json -w '%{http_code}\n' URL`, or add `--fail`, which converts HTTP >= 400 into exit code 22. Observed on a 404: plain curl exits 0, `--fail` exits 22.
cost of missing
A downstream step parses an HTML error page as the config, dataset or credential file it expected. The failure surfaces at the parser, far from the request that caused it.
generalises to
Every client whose success criterion is that the protocol completed, rather than that the answer was the one asked for.
source
curl.se
NS-017 documentedsilent-non-registration

A test that does not match the discovery pattern is neither run nor reported

reads as
pytest exits 0 with a green summary after a new test is added. Conclusion drawn: the new test passes.
actually
Collection matches `test_*.py` or `*_test.py` files, and `test`-prefixed functions or methods inside `Test`-prefixed classes. A file named `tests_auth.py`, or a function named `check_expiry`, is never collected. The green result belongs entirely to the other tests. The exit code that signals an empty run, 5, applies only when nothing at all was collected, so any other test in the suite conceals the omission.
blind because
An uncollected test produces no pass line and no fail line. The summary counts what ran; it has no term for what was skipped by never being seen.
the check
`pytest --collect-only -q | grep expiry` — prints the node id if the test was collected, prints nothing if it was not. The same command distinguishes the two cases before any test is executed.
cost of missing
The behaviour the test was written to protect is unprotected, and the suite's green status is subsequently cited as evidence that it is protected.
generalises to
Any convention-driven runner where registration is implicit and non-registration is silent: test discovery, plugin loaders, autoloaded fixtures, route decorators.
source
docs.pytest.org
NS-018 documentedtest-double-without-a-contract

A bare mock answers to method names the real object no longer has

reads as
The suite is green after a collaborator's method is renamed. Conclusion drawn: nothing depended on the old name.
actually
`Mock()` manufactures an attribute on first access and returns another Mock, which is callable and truthy. Code calling `client.charge_card(...)` against the double passes although the real class now exposes only `charge`. The test exercises an interface that no longer exists, and will keep passing however far the real object drifts.
blind because
The assertion is satisfied by the double's auto-created child. Green is a true statement about the mock, and the exit code cannot say which object the statement was about.
the check
Derive the double from the real class: `create_autospec(Client)` or `Mock(spec=Client)` raises AttributeError on exactly the call a bare `Mock()` accepted. Observed on 3.12: `Mock().exsits()` returns a truthy Mock; `create_autospec(Real).exsits()` raises AttributeError.
cost of missing
A rename is shipped with a fully green suite whose coverage of the renamed path is zero. The regression appears in production, in code the tests appeared to cover.
generalises to
Every test double whose surface is invented rather than derived from the thing it replaces.
source
docs.python.org
NS-019 documentedsilent-coercion

Outside strict mode MySQL stores an adjusted value and calls the statement successful

reads as
The INSERT returns `Query OK, 1 row affected` and the client exits 0. Conclusion drawn: the row was stored as supplied.
actually
With strict mode absent from sql_mode, MySQL 'inserts adjusted values for invalid or missing values and produces warnings'. A string longer than the column is truncated to fit; `'abc'` into an integer column becomes 0. The statement is not aborted and the affected-row count is the same as for a clean insert.
blind because
Warnings are a separate channel that must be asked for. Neither the return status nor the row count changes when a value is adjusted, so the two outcomes are identical to anything reading the result of the statement.
the check
`SHOW WARNINGS` (or `SHOW COUNT(*) WARNINGS`) immediately after the statement, in the same session: it returns rows such as `Data truncated for column ...` only when a value was adjusted, and nothing when it was not.
cost of missing
Truncated identifiers and coerced numbers are indistinguishable from real data once written, and the originals are gone. Corruption is discovered by a later join that finds nothing.
mitigation
Assert the mode rather than assume it: `SELECT @@SESSION.sql_mode` should contain STRICT_TRANS_TABLES before any load is trusted.
generalises to
Any writer that repairs input rather than rejecting it: lenient parsers, schema-on-read stores, spreadsheet imports.
source
dev.mysql.com
NS-053 documentedcollector-discards-child-status

wait without arguments returns zero however its children exited

reads as
A script starts several jobs with `&`, calls `wait`, and exits zero. Conclusion drawn: every job succeeded.
actually
The bash manual is explicit: 'If id is not given, wait waits for all running background jobs and the last-executed process substitution, if its process id is the same as $!, and the return status is zero.' The children's statuses are reaped and discarded. Any number of them may have failed.
blind because
An exit code reports what the last command chose to return, and bare wait returns zero by specification. The failing work happened in processes whose status was never requested.
the check
Wait on each recorded PID and keep the statuses: `rc=0; for p in "${pids[@]}"; do wait "$p" || rc=$?; done; exit $rc`. Observed on bash 5.2.21 with one child exiting 3 and another exiting 7: bare `wait` returned 0, `wait $pid` on the second returned 7, and `wait -n` returned the status of the first job to finish.
cost of missing
Parallelism converts a failing step into a silent one. A fan-out of uploads, migrations or builds reports success while an arbitrary subset of it did not happen.
generalises to
Every aggregator that reduces many statuses to one: parallel test runners, job schedulers, batch APIs, fan-out without per-item bookkeeping.
source
gnu.org
NS-054 documenteddestination-truncated-before-the-source-is-read

Output redirection empties the file before the command reads it

reads as
`sort data.txt > data.txt` exits zero and data.txt is still there. Conclusion drawn: the file was sorted in place.
actually
The shell performs redirections before running the command, and for output redirection 'if the file does not exist it is created; if it does exist it is truncated to zero size'. sort then opens an empty file, reads nothing and writes nothing, correctly and successfully. The original contents are gone.
blind because
The exit code belongs to a command that did exactly what was asked of it with the input it was given. The destruction happened in the shell, before the command started, and produced no status of its own.
the check
Compare the line count before and after in the same command. Observed on bash 5.2.21: a three-line data.txt held zero lines after `sort data.txt > data.txt`, with sort exiting 0; `grep -v DEBUG conf.txt > conf.txt` left conf.txt at zero bytes, with grep exiting 1 because it had nothing to match.
cost of missing
The file that was supposed to be filtered is now empty, and emptiness is valid input to whatever reads it next: configuration becomes all-defaults, a dataset becomes zero records, and neither state raises an error.
mitigation
Write to a new name and rename over the original, or use a tool with an explicit in-place mode such as `sed -i` or `sponge`.
generalises to
Every operation that opens its destination before reading its source: in-place archive rewrites, dumps piped over their own file, copies where source and destination alias.
source
gnu.org
NS-055 documentedstatus-excludes-the-delegated-command

find exits zero regardless of what the command it ran returned

reads as
`find . -name '*.json' -exec validate {} \;` exits zero. Conclusion drawn: every file passed validation.
actually
find's exit status describes find's own traversal. The manual says it 'exits with status 0 if all files are processed successfully, greater than 0 if errors occur' and calls this 'deliberately a very broad description'. The status of each -exec child is not part of it. All of them may have failed.
blind because
One process walked the tree and a different process did the work. The status available to the caller belongs to the one that only walked.
the check
Dispatch through a tool whose status covers the children: `find . -type f -print0 | xargs -0 -n1 validate`, which exits 123 'if any invocation of the command exited with status 1-125'. Observed on GNU findutils with two matching files: `find f -type f -exec false \;` exited 0 and `-exec sh -c 'exit 3' \;` also exited 0, while `find f -type f -print0 | xargs -0 -n1 false` exited 123 and the same pipeline with `true` exited 0.
cost of missing
A validation, conversion or upload sweep reports success across an entire tree while every item in it failed, and a sweep is precisely the step nobody re-checks item by item.
generalises to
Every dispatcher whose status covers dispatch rather than outcome: cron wrappers, CI steps that shell out, message producers acknowledged on enqueue.
source
man7.org
NS-056 documentedargument-resolved-to-a-sibling-path

A source path without a trailing slash adds a directory level at the destination

reads as
`rsync -a build /var/www/site/` exits zero and the files are present under /var/www/site. Conclusion drawn: the build was deployed.
actually
rsync's manual: 'A trailing slash on the source changes this behavior to avoid creating an additional directory level at the destination.' Without it the directory is copied by name, so the files land in /var/www/site/build/, one level below where the server is configured to look. The previous build continues to be served.
blind because
The transfer succeeded and every file was copied correctly to a real path. The exit code describes the copy, not the destination's relationship to whatever reads it.
the check
List the destination rather than trusting the status: `find /var/www/site -maxdepth 2 -name index.html`. Observed on rsync 3.2.7: `rsync -a rs/src rs/dest/` exited 0 and produced rs/dest/src/index.html, while `rsync -a rs/src/ rs/dest/` exited 0 and produced rs/dest/index.html.
cost of missing
The deploy reports success and the site does not change. Re-running it reproduces the same success, so the natural response to the symptom confirms the wrong hypothesis.
generalises to
Every copy whose destination semantics depend on a trailing character or on whether the target already exists: cp, scp, docker COPY, object-storage prefixes.
source
man7.org
NS-080 documentedempty-argument-succeeds-silently

cd with an empty or unset argument succeeds without going anywhere

reads as
`cd "$BUILD_DIR" && rm -rf ./*` exits zero and the script proceeds. Conclusion drawn: the build directory was entered and cleared.
actually
bash(1): change the current directory to dir; if dir is not supplied, the value of the HOME shell variable is the default. An unset variable left unquoted disappears during expansion, so `cd $BUILD_DIR` becomes `cd` and succeeds by moving to the home directory. Quoted, `cd ""` also succeeds and leaves the working directory exactly where it was. Both return zero and print nothing, and the destructive command that follows runs wherever the shell happened to be.
blind because
cd's status reports whether a directory change was performed, never which directory. Arriving where you intended and arriving in the home directory are the same value.
the check
Confirm the destination rather than the status, or refuse an empty value outright: `: "${BUILD_DIR:?BUILD_DIR is empty}"; cd "$BUILD_DIR" && [ "$PWD" = "$BUILD_DIR" ]`. Observed on bash 5.2.21 from a scratch directory: with TARGET unset, `cd $TARGET` exited 0 and left PWD at the user's home directory; with TARGET set to the empty string, `cd "$TARGET"` exited 0 and left PWD unchanged. `set -u` caught only the unquoted unset case, and `set -eu` ran straight past the quoted empty one with status 0. With CDPATH=/usr, `cd bin` from /tmp exited 0 in /usr/bin.
cost of missing
The rm, rsync or build step that follows operates on the wrong tree with full confidence, and in the home-directory case on a tree that contains everything.
mitigation
`${VAR:?}` fails on empty as well as unset, which `set -u` does not; assert on $PWD after any cd whose argument came from a variable.
generalises to
Every command that treats a missing argument as a request for its default rather than as an error: cd, `git checkout`, `kubectl` without a namespace, `docker build` with an empty context path.
source
man7.org
NS-081 documentedstatus-consumed-by-the-declaring-builtin

A declaration builtin consumes the exit status of the substitution it assigns

reads as
`local token=$(fetch_token)` is followed by a status check, and the check passes. Conclusion drawn: fetch_token succeeded and token holds a token.
actually
bash(1) explains the ordinary case: if no command name results and one of the expansions contained a command substitution, the exit status of the command is the exit status of the last command substitution performed. Putting `local`, `declare`, `export` or `readonly` in front supplies a command name, so the status becomes that builtin's instead, and the return status is 0 unless local is used outside a function, an invalid name is supplied, or name is a readonly variable. The substitution's failure is discarded and the variable holds an empty string.
blind because
One line performs two operations and reports on the outer one. The status is a true statement about whether a variable was declared, offered where a statement about whether a value was obtained is expected.
the check
Separate the declaration from the assignment and compare the two forms: `local token; token=$(fetch_token)`. Observed on bash 5.2.21: `f(){ local out; out=$(false); echo $?; }` printed 1, `g(){ local out=$(false); echo $?; }` printed 0, and `h(){ export OUT=$(false); echo $?; }` printed 0. Under `set -e` the split form aborted the shell and the combined form ran on to completion returning 0.
cost of missing
An empty credential, empty version string or empty path is carried forward and fails somewhere far from its origin, usually as an authentication error or a path that resolves to the filesystem root.
mitigation
Declare on one line and assign on the next wherever the command's outcome matters; `set -e` only helps once the two are separated.
generalises to
Every wrapper that reports on itself rather than on what it invoked: shell builtins in front of assignments, test harnesses swallowing setup failures, entrypoints exiting on the shell's status rather than the program's.
source
man7.org
NS-082 documentedpattern-survives-as-a-literal

An unmatched pattern is passed through as a literal filename

reads as
`for f in releases/*.tar.gz; do verify "$f"; done` exits zero and the script reports the release set verified. Conclusion drawn: every archive was checked.
actually
bash(1): if no matching filenames are found, and the shell option nullglob is not enabled, the word is left unchanged. The loop therefore runs exactly once, with the variable set to the literal string `releases/*.tar.gz`, which names nothing. Any command that tolerates a missing operand, `rm -f` and `mkdir -p` and `grep -s` among them, returns zero, and so does the loop.
blind because
Zero iterations and one iteration over a path that does not exist yield the same exit status. The difference lives in a count that nothing reports, and a wrong directory, a typo in an extension and an empty build all produce it identically.
the check
Count what the pattern matched instead of what the loop returned: `shopt -s nullglob; files=(releases/*.tar.gz); echo "${#files[@]}"`, and fail on zero. Observed on bash 5.2.21 in an empty directory: `for fn in *.log; do echo "[$fn]"; done` printed `[*.log]` and exited 0; `rm -f *.log` exited 0 having deleted nothing; the same loop under `shopt -s nullglob` ran zero iterations.
cost of missing
A cleanup, upload or signing step reports success over an empty set, so the artefacts it was meant to handle survive untouched, and the next stage consumes the previous release without noticing it is stale.
mitigation
Enable `nullglob` and assert on the array length, or `failglob` where an empty match is always a bug.
generalises to
Every operation over a collection that is silently empty: globs, empty pipelines into xargs, queries returning no rows, iterations over an unset list.
source
man7.org

Plain text: /exit-code.txt · all instruments