Back to the course
Test yourself

Bash for ops, done safely

Final exam · 42 questions · answers explained as you pick
First scripts
13 questions
01To make a script stop on errors, someone changes its first line to #!/usr/bin/env bash -e. Now ./sync.sh fails at once with env: 'bash -e': No such file or directory. What happened?
Incorrect — The message names bash -e, not bash: env received one word with a space in it. The same PATH works as soon as the -e is gone.
Correct — Everything after the interpreter path on a shebang line arrives as a single argument. Put set -e (or the full strict header) on a line inside the script instead.
Incorrect — Bash accepts -e (it is set -e). The error comes from env, which never got as far as starting Bash.
Incorrect — The kernel does not reject the extra text; it hands it to the interpreter as one argument, which is how env came to see bash -e.
02To save time, an operator types source ./deploy.sh in an SSH session instead of ./deploy.sh. The script ends with exit 1 when its health check fails, and tonight it fails. What does the operator see?
Incorrect — That is what happens to a child process. A sourced file runs in your own shell, so its exit ends that shell.
Incorrect — Bash has no such rule; source runs the lines as if you had typed them, exit included.
Incorrect — exit is not skipped. It runs in the current shell and ends it.
Correct — source runs the file inside the interactive shell, so exit 1 ends that shell and the connection with it. Run programs by path; source only files meant to change your shell.
03Given backup_dir=/srv/backups, host=web01 and today=2026-09-27, what does echo "$backup_dir/$host_$today.tar.gz" print?
Correct — $host_ asks for a variable named host_, which is unset and expands to nothing, without a warning. Write ${host}_$today when text follows a name directly.
Incorrect — That needs braces: ${host}_. Without them Bash takes the longest run of name characters, host_, as the variable name.
Incorrect — $today expands normally; the dot ends that name. The missing piece is the host, because host_ is a different, unset variable.
Incorrect — That message needs set -u, which this script does not have. Without it an unset name becomes an empty string with no warning.
04A deploy job fails, so an operator runs bash -x ./deploy.sh 2> debug.log and attaches debug.log to a ticket. The script reads its API token with token=$(cat /etc/deploy/token). What has the operator shared?
Incorrect — xtrace prints each command after expansion. For an assignment that means the name and the value it received.
Incorrect — The trace shows ++ cat /etc/deploy/token for the substitution and then the assignment with the value it produced.
Correct — Tracing prints + token= followed by the value, and 2> wrote it into the file. Treat the log as holding the secret: delete it and rotate the token.
Incorrect — Bash writes the trace to standard error, so 2> debug.log captured all of it, the token included.
05A crontab without a SHELL= line must keep both the output and the error messages of /opt/ops/sync.sh in /var/log/sync.log. Which entry does that?
Incorrect — Cron runs the line with /bin/sh (Dash), which reads &> as "run in the background" followed by an empty > file, so the log stays empty.
Incorrect — Left to right: stderr copies stdout's target while that still points where cron collects output, and only then does stdout move to the file. Errors never reach the log.
Correct — Stdout goes to the file first, then stderr copies that destination. This is POSIX and means the same under sh and Bash.
Incorrect — That captures the errors alone; the normal output goes wherever cron sends it.
06A report loop prints each CSV field with echo "$field", and a field that holds -n produces no output at all. Why is printf '%s\n' "$field" the fix rather than more quoting?
Correct — "$field" arrives as one argument, -n, and Bash's echo takes it as "no newline". printf reads its first argument as the format, so the value is printed as data.
Incorrect — Quote removal takes away the quote characters, not their effect: the value still arrives as exactly one argument. The problem is what echo does with it.
Incorrect — The field is not empty; it is -n, which echo treats as an option. An empty field would print an empty line.
Incorrect — Bash's echo does not understand --; it prints -- -n literally. That is one more reason to use printf for data.
07A wrapper asks read -r -p "Directory to archive: " dir and then archives "$dir". When cron starts it, standard input is empty. The log shows no prompt and the job reports success. What went wrong?
Incorrect — -p only decides whether a prompt is printed. With no input, read fails and leaves dir empty; there is no old value to keep.
Incorrect — read does not exit anything; it returns a status. At end of input that status is non-zero, and nobody checked it.
Incorrect — Empty input is end of input, so read returns at once. The lesson's ./ask.sh < /dev/null finished immediately.
Correct — Wrap it as if ! read -r ... dir; then echo "no input" >&2; exit 1; fi so a run without input fails instead of archiving the wrong thing.
08A check runs grep -q 'Accepted publickey for root' auth.log, then log "auth.log checked" (a function that prints to stderr and succeeds), then if [[ $? -eq 0 ]]; then alert; fi. When does alert run?
Incorrect — $? no longer holds grep's status when the if runs; the log call in between replaced it.
Correct — $? is overwritten by each command. Save it with rc=$? on the line right after grep, or put grep directly in the if.
Incorrect — A function's status lands in $? like any command's; here that status is 0, so the alert fires.
Incorrect — grep's status is already gone; whatever grep returned, the test reads the 0 from log.
09A daily report runs if ! grep -q 'Failed password' /var/log/auth.log.1; then echo "no failed logins"; fi. After a rotation change, auth.log.1 no longer exists. What does the report say, and what is the better design?
Correct — ! grep sends 1 (no match) and 2 (could not read) into the same branch. Keep the status (rc=0; grep ... || rc=$?) and fail on anything above 1.
Incorrect — Nothing stops a script on a failed command unless it asks for that, and this failure sits in a tested condition anyway.
Incorrect — ! turns any non-zero status into 0, so the "no failed logins" branch runs.
Incorrect — A missing log means the check could not run. Reporting it as clean hides the gap on exactly the morning someone should look.
10A job sets failures=50 and reads THRESHOLD=08 from a config file (someone meant eight). It runs [[ $failures -gt $THRESHOLD ]] && page_oncall. What happens?
Incorrect — Bash reads a leading zero as octal (base 8), and 8 is not an octal digit, so the comparison never happens.
Incorrect — -gt is an arithmetic comparison; text comparison is < and >. And the value is rejected before any comparison.
Incorrect — Bash prints that error, but the script carries on: the failed test only counts as false.
Correct — An error inside a test is just "false". Validate with [[ $THRESHOLD =~ ^(0|[1-9][0-9]*)$ ]] where the value enters, and exit 2 when it fails.
11Why does [[ $reply = yes ]] behave correctly when reply holds yes please, while [ $reply = yes ] fails with "too many arguments"?
Incorrect — [[ compares the whole value, yes please, and correctly answers "not equal". Nothing is trimmed.
Incorrect — [ is a shell builtin, as type showed. It is an ordinary command, and that is the point: its arguments are split first.
Correct — [ is a builtin command whose arguments go through word splitting, so $reply became two words. Inside [ ], quote every variable.
Incorrect — [ compares strings with =. It failed because it received four words where it expected three.
12A script with set -e counts processed files with count=0 and then (( count++ )) inside its loop. What happens on the first file?
Incorrect — (( )) returns 1 when the expression's value is 0, and count++ hands back the old value, 0.
Correct — count++ evaluates to 0 before adding one, so (( )) returns 1 and errexit stops the script silently. Count with count=$((count + 1)).
Incorrect — ++ is valid arithmetic; the lesson ran (( n++ )) and n was 1 afterwards.
Incorrect — Nothing is rolled back: n was 1 afterwards in the lesson. Only the status says "false".
13firewall.sh decides what to do with case "$1" in open) ...;; close) ...;; esac and then prints "firewall updated". An automation tool calls it with closed. Which change makes that call fail closed?
Correct — A case with no match returns 0, so without a default the typo prints "firewall updated". A refusing default turns it into a visible usage error.
Incorrect — A case with no match succeeds, so set -e has nothing to act on.
Incorrect — The first matching pattern wins, and * matches everything, so open and close would never run.
Incorrect — That helps with Close, not with closed, and unknown values would still fall through silently.
13 questions · explanations appear as you answer
Working with data
12 questions
01sftp.conf holds one line, ForceCommand internal-sftp, indented with four spaces. What do mapfile -t lines < sftp.conf; printf '[%s]\n' "${lines[0]}" and read -r line < sftp.conf; printf '[%s]\n' "$line" print?
Incorrect — -r only stops backslash processing. Without IFS=, read still trims spaces and tabs at the start and end of the line.
Incorrect — mapfile stores each line as it is; -t removes only the newline at its end.
Correct — read without IFS= trims the whitespace in IFS from both ends. IFS= read -r line keeps the four spaces, as mapfile did.
Incorrect — It is the other way round: mapfile keeps the line exactly, and the plain read trims it.
02A cleanup must delete matching files under a shared upload directory and afterwards report how many it deleted. Names may contain spaces or newlines. Which loop shape meets both needs?
Incorrect — The unquoted substitution splits names at spaces and newlines and expands any * in them, so names arrive broken.
Incorrect — Newline-separated names break on a name that contains a newline, and the pipe runs the loop in a subshell, so the count is lost.
Incorrect — Names now arrive whole, but the loop still runs in a subshell because of the pipe, so the counter is 0 afterwards.
Correct — NUL separators keep each name whole, and < <( ) keeps the loop in the current shell, so its counter survives.
03A setup script contains backup_dir="~/backups" and later mkdir -p "$backup_dir". Run from /srv/app, what does it create?
Incorrect — mkdir does not expand ~; the shell does, and only when the tilde is unquoted at the start of a word.
Correct — The quotes kept ~ literal at assignment, and the result of an expansion is not tilde-expanded again. Write backup_dir="$HOME/backups".
Incorrect — ~ is an ordinary character in a file name; mkdir created it without complaint.
Incorrect — ~ has no special meaning inside quotes; it stayed a literal character, so the path is ~/backups relative to the current directory.
04A nightly cleanup runs find /srv/share -name '*.bak' -mtime +30 | xargs rm -f. Its log shows xargs: unmatched single quote, and many old backups remain. What is the cause and the fix?
Correct — A name like it's.bak stops xargs, and a b.bak becomes two names. find ... -print0 | xargs -0 rm -f -- (or find ... -exec rm -f -- {} +) passes names whole.
Incorrect — rm never saw those names: xargs stopped reading at the apostrophe. -- protects against leading dashes, not quotes.
Incorrect — That quoting is what ls does at a terminal. find prints names raw, and xargs then misreads them.
Incorrect — find matched them; the names were damaged later, when xargs split its input.
05A helper reads is_prod() { if [[ $(hostname) == prod-* ]]; then echo yes; else echo no; fi; }, and a deploy step runs if is_prod; then enable_prod_alerts; fi on the host web01. What happens?
Incorrect — The caller never looks at the printed word. if tests the function's exit status.
Incorrect — A function can be an if condition like any command; if valid_name "$n" in the lesson worked this way.
Incorrect — The function's last command is echo no, which succeeds, so it returns 0.
Correct — The function answers with output while the caller asks for a status. Write is_prod() { [[ $(hostname) == prod-* ]]; } so the test's status is the answer.
06A script sets dir=/srv/app/cache, calls make_stage() { dir=$(mktemp -d); }, and then runs rm -rf -- "${dir:?}"/* to empty the cache. What does the rm empty?
Incorrect — Without local, an assignment in a function changes the script's global variable, and that change stays.
Incorrect — dir holds the temp path, which is not empty, so :? lets it through.
Correct — Variables are global unless declared local. Use local dir inside the function, or a different name for a different thing.
Incorrect — A variable holds one value at a time; the assignment replaced /srv/app/cache.
07valid_name accepts a job name if it matches ^[A-Za-z0-9_][A-Za-z0-9._-]*$ and refuses it otherwise. Why is that safer than refusing names that contain / or ..?
Correct — A denylist of / and .. would have let -rf, .hidden and a name with a newline through; the allowlist refused them without anyone thinking of them.
Incorrect — Speed is not the point; the difference is which unexpected inputs get through.
Incorrect — The pattern contains no space, so a spaced name is refused, and a denylist of / and .. would not block spaces anyway.
Incorrect — Matching does not change the value or how it is expanded later; quoting where it is used still matters.
08With files=('q3 report.pdf' notes.txt), a script runs cp -- "${files[*]}" /srv/share/. What does cp do?
Incorrect — That is "${files[@]}". "${files[*]}" joins the elements into one word.
Correct — [*] joins the elements with spaces into a single argument. Use "${files[@]}" to pass each element as its own argument.
Incorrect — Inside double quotes nothing is split. Three words would need the unquoted form.
Incorrect — Elements with spaces are fine in arrays; the joined word just does not name any file.
09set -u is on, and the purge line is rm -rf -- "/srv/cache/$TARGET/"*. A typo in the cron file leaves TARGET= empty rather than unset. What does the next run do?
Incorrect — -u fires for names that were never set. TARGET is set, to an empty string.
Incorrect — A double slash is a valid path and means the same as a single one.
Incorrect — /srv/cache//* matches each environment's directory, so it has plenty to delete.
Correct — set -u lets an empty value through. Write "${TARGET:?TARGET must be set}", which stops on empty and unset alike, plus an allowlist for wrong values.
10An SSH log line ends Failed password for invalid user x from 198.51.100.99 port 1 from 203.0.113.7 port 50412 ssh2, and the user name was chosen by the client. A script runs addr=${line#* from }; addr=${addr%% *}. What is addr?
Incorrect — # removes the shortest match, which ends at the first " from ", inside the attacker's user name.
Incorrect — The pattern is * from , so the removal runs up to the first " from ", not the first space.
Correct — # stops at the first " from ". Use ##, which removes up to the last " from ", the one sshd writes after the user name.
Incorrect — Glob patterns in # can contain spaces; the removal worked, it just stopped too early.
11Under while getopts ':d:n' opt, the command line ./prune.sh -n -d is parsed. For the -d pass, which values do opt and OPTARG get?
Correct — In silent mode (the leading :), a missing value sets opt to : and puts the letter in OPTARG; the script prints its own message and exits 2.
Incorrect — getopts does not hand over an option without its value; it reports the problem through opt.
Incorrect — ? is for letters that are not in the option string. d is known; its value is missing, which is :.
Incorrect — The leading : switches getopts' own messages off, and getopts does not end a script; the case branch decides what happens.
12./report.sh --help | less shows an empty screen, and ./report.sh --help; echo $? prints the help and then 2. What is wrong with how the script handles --help?
Incorrect — Help that the user asked for is normal output; putting it on standard error is the bug here.
Correct — The user asked for help, and the script did what was asked. Keep standard error and status 2 for a wrong command line.
Incorrect — less shows any text; it showed nothing because the help went to standard error, which the pipe does not carry.
Incorrect — Status 2 means "called wrongly", which --help is not; a caller or wrapper would treat a request for help as a failure.
12 questions · explanations appear as you answer
Writing Bash safely
13 questions
01A script with set -e does its work and ends with the line [[ ${VERBOSE:-} == 1 ]] && echo "report sent". Run without VERBOSE, each step succeeds. What exit status does the scheduler record?
Incorrect — -e ignores it, but it is still the last command, so its status 1 becomes the script's status.
Incorrect — A script's status is that of the last command it ran, successful or not, which here is the false test.
Incorrect — ${VERBOSE:-} gives an empty string, and the comparison is simply false: status 1.
Correct — Write it as if [[ ${VERBOSE:-} == 1 ]]; then echo "report sent"; fi; an if whose condition is false has status 0.
02In a strict script, install_conf || die "install failed" let a failed cp inside install_conf go unnoticed. A reviewer rewrites the call as if ! install_conf; then die "install failed"; fi. Does that fix it?
Correct — The function body still runs unchecked and returns its last command's status. The function has to check its own cp (|| return 1), or be called as a plain statement.
Incorrect — ! inverts the function's final status, which comes from the echo after the failed cp, so it is still 0 and die does not run.
Incorrect — errexit is off in the condition of if, and the function call is that condition, body included.
Incorrect — ! switches errexit off for that command; it does not make the script exit.
03Capturing the first ERROR line through grep | head -n 1 dies with 141 on large logs under pipefail. Which rewrite avoids the 141 but keeps pipefail on?
Incorrect — true to the assignment, so a 141 cannot stop the script || That also hides status 2 when the log cannot be read, and leaves the value empty with nothing reported.
Incorrect — That brings back the silent pipeline: a failed grep followed by a successful stage reports 0.
Correct — With no pipe there is no SIGPIPE. Keep the status (|| rc=$?) and treat 1 as "no match" and 2 as an error.
Incorrect — SIGPIPE kills grep without a message; the problem is the status 141, which redirecting stderr does not change.
04Why is count=$(grep -c ERROR "$log" || true) a poor way to stop a strict script from exiting when there are no matches?
Incorrect — true runs inside the subshell, so the script still exits on status 1 || The || true makes the substitution succeed, so the script does not exit; the problem is what else it swallows.
Correct — Record the status instead (rc=0; count=$(...) || rc=$?), accept 1 and fail on 2.
Incorrect — grep -c prints 0 when nothing matches; the empty value comes from the missing file.
Incorrect — $? is replaced by each command anyway; errexit stays on for every later line.
05maint.sh creates maintenance.on, which keeps the load balancer's traffic away, and sets trap 'rm -f -- maintenance.on' TERM EXIT. It receives SIGTERM during step 1 of 3. Steps 2 and 3 still run while traffic arrives, and the script exits 0. What went wrong?
Incorrect — EXIT traps do not block signals. The TERM handler did run, which is why the flag vanished at once.
Incorrect — The signal went to the script; the flag disappearing shows its handler ran.
Correct — A handler replaces the default "terminate". Keep the rm in the EXIT trap and make the TERM handler re-raise: trap 'trap - TERM; kill -s TERM "$$"' TERM.
Incorrect — A signal ends a script whatever set -e says; errexit reacts to failed commands, not to signals. Here the handler replaced the default action.
06A strict script (set -euo pipefail) ends with exit 0 and has trap 'rm -- "$work/partial.tmp"; rm -rf -- "$work"' EXIT. On runs that never created partial.tmp, it exits 1 and leaves $work behind. Why?
Incorrect — The EXIT trap runs on every exit. The rm error message in the log is the trap running.
Correct — The trap runs under the script's own set -e, so the first failing command ends it and its status replaces 0. rm -f -- cannot fail on a missing file.
Incorrect — The trap runs in the same shell and sees $work; the message names the full path of partial.tmp.
Incorrect — A trap runs in the shell that set it. The second command never ran, because the first one failed and ended the trap.
07A script sets trap 'log "stopping"; trap - TERM; kill -s TERM "$$"' TERM and then runs a 10-minute rsync in the foreground. One minute in, kill -TERM is sent to the script's PID. When does the handler run?
Incorrect — A trapped signal does not interrupt the foreground command; Bash runs the handler when it gets control back.
Incorrect — The handler does run, just later; the lesson's long-step.sh printed "TERM handler runs" after its command finished.
Incorrect — Bash does not run the handler while it waits for the foreground command; it waits for rsync first.
Correct — Bash runs a trap when the foreground command completes. To react at once, run the long command in the background and wait for it, which a trapped signal interrupts.
08Three failed runs of a Bash job with trap cleanup EXIT ended with the statuses 130, 143 and 137. Which run cannot have run its cleanup?
Correct — 137 is 128 + 9. SIGKILL, like SIGSTOP, cannot be caught, so plan for leftovers, for example with a fresh mktemp directory per run.
Incorrect — Bash runs the EXIT trap after an untrapped SIGINT or SIGTERM too, before it exits with 130 or 143.
Incorrect — Without a handler, Bash dies of SIGTERM but still runs the EXIT trap on the way out, as the lab showed with prep.sh.
Incorrect — No trap runs for SIGKILL. That is why tools that stop processes send SIGTERM first and SIGKILL only after a grace period.
09A job on Ubuntu 26.04 writes its report with > "/tmp/report.$$", on the grounds that the PID makes the name unique. Why is that still unsafe?
Incorrect — $$ is the script's PID even inside a subshell. The problem is who else can guess it.
Incorrect — Nothing clears /tmp on write; systemd-tmpfiles removes entries unused for 10 days, and a reboot empties the tmpfs.
Correct — On 26.04 fs.protected_regular then refuses the write (Permission denied); where it does not apply, the job writes into their file or through their link. mktemp creates a fresh 0600 file and fails on an existing name.
Incorrect — Uniqueness among running processes does not stop another user from creating the file first. PIDs are visible with ps and handed out in order, so the name is easy to guess.
10A job guarded by exec 9> "$lock_dir/job.lock" and flock -n 9 || exit 3 was killed with kill -9 in the middle of a run. What does the next run find?
Incorrect — That is the failure of a hand-made "lock file exists" check. A flock lock belongs to the open file, and the kernel dropped it when the process ended.
Incorrect — The file stays; only the lock is released. Leaving the file in place is also what keeps the locking correct.
Incorrect — Locks are tied to open files, not to PIDs; the last descriptor closed when the process died.
Correct — flock needs no cleanup after a crash, which is why the lock file can stay forever and should not be deleted.
11A health check runs timeout 30 grep -q "$marker" /srv/app/status.txt and pages "status check timed out" whenever the status is not 0. Tonight the page fires, yet the check took a second. What happened?
Correct — A command that finishes in time keeps its own status. 124 (and 137 after -k) are the numbers that mean the limit was hit, so test for those.
Incorrect — timeout knows nothing about output; it returns the command's status, or 124 when time ran out.
Incorrect — That would have taken 30 seconds and given 124; the check finished in a second.
Incorrect — timeout does not alter a status; timeout 5 sleep 1 returned 0 in the lesson.
12ShellCheck marks local stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ) with SC2155 (warning): Declare and assign separately to avoid masking return values. What would go wrong at run time?
Incorrect — local works on this line; the variable is local. The problem is the status the line reports.
Incorrect — The substitution's output is assigned normally; stamp gets the date when date succeeds.
Correct — The line's status is that of local. Write local stamp and then stamp=$(date ...), so the assignment carries the substitution's status.
Incorrect — Assignments are not split, and SC2155 is not about quoting; SC2086 is the quoting code.
13A Bats test runs run ./backup.sh --keep 0 app, then checks [ "$status" -eq 2 ] and [[ $output == *"--keep must be"* ]]. The script prints that message on standard error. What happens?
Incorrect — run records the real exit status in $status; it stops the failure from ending the test and nothing more.
Correct — run does not let the command's failure end the test, stores its exit status in $status, and puts standard output and standard error together in $output.
Incorrect — run captures both streams together in $output, which is how the lesson's tests matched messages the scripts wrote to stderr.
Incorrect — That is what a bare command does in a Bats test. run exists to capture the failure instead.
13 questions · explanations appear as you answer
Putting it together
4 questions
01In backup.sh, verify_archive first runs tar -tzf "$work/$archive" > "$work/listing" || die "cannot read back $archive" and then loops over $work/listing. Why not feed the loop with done < <(tar -tzf ...) instead?
Incorrect — The loop reads whole lines with IFS= read -r, so spaces survive either way.
Incorrect — With < <( ) the loop stays in the current shell; the pipe form is the one that uses a subshell.
Correct — The lesson's lab showed it: tar failed, the loop saw no input, and the shell went on with status 0 under strict mode. The file lets || die see tar's status.
Incorrect — tar -t writes its listing to standard output wherever that goes; the problem is the status, not the destination.
02publish in backup.sh runs mv -- "$work/$archive.sha256" "$dest/" before mv -- "$work/$archive" "$dest/". What does that order guarantee?
Correct — Each mv is an atomic rename within DEST's file system, and with the checksum moved first no restore job can find an archive without its .sha256.
Incorrect — mv verifies nothing; the checksum was checked inside the work directory before publishing.
Incorrect — Nothing removes the moved checksum; the order is about what a reader can see, not about undoing a step.
Incorrect — Two mv commands are two renames; the order decides which file appears first.
03In main, a dry run calls check_source, lists the plan with tar -cvf /dev/null "${tar_opts[@]}", runs prune and returns, all before trap cleanup EXIT, take_lock and mktemp. On a DEST where the job has never run, what does ./backup.sh --dry-run --dest backups app leave on disk?
Incorrect — The work directory is made by mktemp after the lock is taken, and a dry run returns long before that.
Incorrect — take_lock, which creates .app.job and the lock file, comes after the dry-run branch has returned.
Incorrect — check_source writes the record when dry_run is 0; in a dry run it compares with a record that already exists and writes nothing.
Correct — Each step that creates something comes after the dry-run branch returns, which is why the Bats test finds find "$dest" -mindepth 1 empty after a dry run.
04cron runs timeout -k 5m 6h .../backup.sh ... at 02:15. This morning backup.log shows archiving ... at 02:15, then backup.sh exit status 137 written at 08:20, and ls -A backups/.app.job prints lock source work.k3Xq9Z. What does this show?
Incorrect — A held lock gives status 3 and a log line saying another run holds it; this run had been archiving for six hours.
Correct — Under the schedule's timeout, 137 is the documented "killed after the grace period". SIGKILL runs no trap, so work.k3Xq9Z stayed; the lock was released when the process died. Find out why it hung (a stuck mount, for example), remove the leftover, and leave lock in place.
Incorrect — 137 is SIGKILL from any source, but 02:15 plus 6 hours plus 5 minutes is exactly when timeout -k 5m 6h sends SIGKILL.
Incorrect — A failed tar ends in die with 1 (tar's own fatal status would be 2); a status above 128 means death by a signal.
4 questions · explanations appear as you answer