# Q1: Why does bash background_process.sh produce no output for jobs ?
1 | cat background_process.sh |
1 |
|
1 | bash background_process.sh |
Answer:
Because jobs only reports background jobs that belong to your current shell’s job table — not jobs started by some other shell process.
Here’s what happens step by step:
bash background_process.shstarts a new, separate bash process (a child shell) to run the script.- Inside that child shell,
sleep 300 &,sleep 400 &,sleep 500 &are started as background jobs — but they’re background jobs of that child shell, tracked in its job table. - The script finishes almost instantly (backgrounding doesn’t block), so the child bash process exits right after launching the three sleeps.
- When the child shell exits, control returns to your interactive shell. But your interactive shell never itself did
sleep 300 &/400 &/500 &— some other (now-dead) shell did. So those sleep processes don’t show up in your shell’sjobslist. They keep running in the background as orphaned processes (reparented to init/systemd), just invisible tojobs. - Contrast this with your next command,
sleep 300 &, typed directly into your interactive shell. That job is registered in your shell’s own job table, sojobsimmediately reports[1] 66560and later[1]+ Running sleep 300 &.
Key point: jobs is a shell built-in that only knows about the jobs that shell itself launched into the background. It has no visibility into background jobs created by a subshell/script that has already exited — even though those processes are still alive.
If you want to confirm the sleep processes from the script are still running, use:
1 | ps aux | grep sleep |
instead of jobs , since ps inspects the whole process table on the system, not just your shell’s job list.
If you want the script’s background jobs to be visible in jobs in your current shell, source the script instead of running it in a subshell:
1 | source background_process.sh |
This runs the script’s commands directly in your current shell, so the sleep & jobs become part of your shell’s own job table and jobs will show them.
# Q2: Kill all the sleep processes
Answer:
1 | pkill sleep |
This sends SIGTERM to every process whose command name matches sleep .
Alternative options:
-
If
pkillisn’t available or you want to be more explicit:1
killall sleep
-
If you want to see what will be killed first (safer, especially on shared systems):
1
ps aux | grep '[s]leep'
(the
[s]leepbracket trick avoids matching thegrepcommand itself)Then kill by PID:
1
kill 66560 66561 66562
-
If some sleep processes don’t respond to SIGTERM, force kill with:
1
pkill -9 sleep
Note: pkill sleep / killall sleep will kill every sleep process on the system owned by your user — not just the ones from your script. If other users or processes rely on a sleep command, be cautious.
# Q3: pkill usage
Answer:
pkill sends a signal (default SIGTERM) to processes matching a name or other criteria, without needing to know PIDs. General syntax:
1 | pkill [options] pattern |
Common options:
| Option | Meaning |
|---|---|
-9 |
Send SIGKILL instead of SIGTERM (force kill) |
-f |
Match against the full command line, not just the process name |
-u user |
Only match processes owned by user |
-x |
Require an exact match of the name (not a substring) |
-l |
List signal name/number too (when combined with -e ) |
-e |
Show the names/PIDs of processes that were killed |
-n |
Kill only the newest matching process |
-o |
Kill only the oldest matching process |
-i |
Case-insensitive match |
-t terminal |
Only match processes on a specific terminal |
-signal |
Send a specific signal, e.g. -HUP , -INT , -STOP |
Examples:
1 | pkill sleep # kill all processes named "sleep" |
Check before killing (recommended):
1 | pgrep -a sleep # lists PIDs + full command lines matching "sleep" |
pgrep uses the same matching options as pkill but only lists PIDs instead of killing them — a good way to preview what pkill would affect.
Caution:
pkillmatches by regex by default (not exact string), sopkill sleepwould also match a hypothetical process namedsleeper.- Without
-u, it can kill any process you have permission to kill, not just ones from your terminal/script. - Use
-9only as a last resort — try the default SIGTERM first so processes can clean up before exiting.
# Q4: Explain the code
1 | ls non_existent_directory > all_output.txt 2>&1 |
Answer:
This command tries to list a directory that doesn’t exist, and redirects both standard output and standard error into the same file. Let’s break it down piece by piece:
1 | ls non_existent_directory |
Runs ls on a directory called non_existent_directory . Since it doesn’t exist, this command will fail and print an error message like:
1 | ls: cannot access 'non_existent_directory': No such file or directory |
That error message goes to stderr (file descriptor 2 ), not stdout (file descriptor 1 ) — ls doesn’t produce any normal output in this case since there’s nothing to list.
1 | > all_output.txt |
This redirects stdout (file descriptor 1 ) to the file all_output.txt . By default, > only redirects stdout — descriptor 1 implicitly. If this were the only redirection, the error message (which goes to stderr) would still print to your terminal, and all_output.txt would be created but empty.
1 | 2>&1 |
This is the key part. It means: “redirect file descriptor 2 (stderr) to wherever file descriptor 1 (stdout) is currently pointing.”
Since 1 was just redirected to all_output.txt , this makes stderr go to all_output.txt as well.
Order matters here. Redirections are processed left to right:
> all_output.txt→ stdout now points toall_output.txt2>&1→ stderr now points to wherever stdout currently points, which isall_output.txt
So both streams end up in the same file.
Result:
After running this command:
- Nothing prints to your terminal.
all_output.txtis created (or overwritten) and contains:1
ls: cannot access 'non_existent_directory': No such file or directory
Common pitfall — order reversed:
1 | ls non_existent_directory 2>&1 > all_output.txt |
This does not do the same thing! Here, 2>&1 runs first, so stderr is redirected to wherever stdout currently points — which at that moment is still the terminal. Then stdout is redirected to all_output.txt . Result: the error still prints to your terminal, and all_output.txt is empty. This is a classic gotcha, so the original order ( > file 2>&1 ) is the correct idiom for capturing both streams into one file.
Shorthand equivalent (bash 4+):
1 | ls non_existent_directory &> all_output.txt |
&> is a bash-specific shorthand that redirects both stdout and stderr to the file in one go, equivalent to > file 2>&1 .
# Q5: Why not 2>1 but 2>&1
Answer:
# 2>1 — probably not what you want
1 | command 2>1 |
Here, 1 is interpreted as a filename — literally a file called 1 in the current directory. This creates (or overwrites) a regular file named 1 and writes stderr into it. It has nothing to do with file descriptor 1 (stdout); bash just sees a plain string after > and treats it as a filename target.
So if you run:
1 | ls non_existent_directory 2>1 |
You’ll end up with a file named 1 in your current directory containing the error message — not a merge with stdout at all.
# 2>&1 — redirect to a file descriptor
1 | command 2>&1 |
The & before the 1 is what changes the meaning entirely. It tells bash: “don’t treat 1 as a filename — treat it as file descriptor 1 .”
So 2>&1 means: “make file descriptor 2 (stderr) point to the same destination that file descriptor 1 (stdout) currently points to.”
The & is the signal that disambiguates a number from a target. Without it, bash has no way of knowing whether you mean “the file named 1” or “the stream identified as 1” — so the syntax requires & explicitly to mean “this is a descriptor reference, not a filename.”
# Side-by-side comparison
| Syntax | Meaning |
|---|---|
2>1 |
Redirect stderr to a file literally named 1 |
2>&1 |
Redirect stderr to wherever stdout (fd 1) currently points |
1>&2 |
Redirect stdout to wherever stderr (fd 2) currently points |
&>file |
Bash shorthand: redirect both stdout and stderr to file |
# Quick way to remember it
Think of &1 and &2 as “the thing at descriptor 1 / descriptor 2,” not the numbers themselves. The & essentially means “this is a descriptor, not a filename.”
So:
1 | ls non_existent_directory 2>1 # creates a file called "1" with the error in it (surprising!) |
If you ever run a command and mysteriously find a file named 1 or 2 sitting in your directory, that’s almost always a sign someone forgot the & .
# Q6: Crontab schedule — why */1 not just 1
1 | */1 * * * * /bin/sh /root/date-script.sh |
Answer:
This comes down to how cron’s field syntax works. Let’s break down the schedule:
1 | */1 * * * * /bin/sh /root/date-script.sh |
# */1 vs 1 — very different meanings
*/1 in the minute field means: “every 1 minute, starting from the full range * (0-59).”
The */N syntax is a step value applied to a range. * means “the entire range of valid values” (0 through 59 for minutes), and /1 means “take every 1st value from that range” — i.e., every single minute. So */1 = run every minute, forever, all day, every day.
1 in the minute field means something completely different: “run only when the minute equals exactly 1” — i.e., only once per hour, at minute :01 (12:01, 1:01, 2:01, etc.).
So these are not equivalent at all:
| Schedule | Meaning |
|---|---|
*/1 * * * * |
Every minute, all day |
1 * * * * |
Once per hour, at minute 1 (e.g. 12:01, 1:01, 2:01…) |
* * * * * |
Every minute (identical to */1 * * * * ) |
# Why write */1 instead of just * ?
Functionally, */1 and plain * in the minute field are identical — both mean “every minute.” People write */1 mostly for:
- Clarity/habit — carrying over the step-value pattern from other fields (e.g.
*/5for every 5 minutes), even when the step is 1 and thus redundant. - Explicitness — some admins like seeing “every 1” spelled out to make the interval obvious at a glance, rather than relying on the reader to know
*alone means “every unit.”
But strictly speaking, * * * * * and */1 * * * * do exactly the same thing — running the script every single minute.
# The step syntax */N in general
*/N means “every Nth value in the range,” e.g.:
*/5in minutes → every 5 minutes (0, 5, 10, 15, …)*/2in hours → every 2 hours (0, 2, 4, 6, …)
It’s shorthand for a comma-separated list: */15 in minutes is equivalent to 0,15,30,45 .
So in the example, if the intent was “run this script every minute,” */1 works but is a slightly verbose way of writing * . If the intent was “run once an hour at minute 1,” the field should just be 1 , not */1 .
# Q7: sh loop.sh fails with [[: not found
1 | cat loop.sh |
1 |
|
1 | sh loop.sh |
Answer:
The error happens because you ran the script with sh , not bash — even though the script has a #!/bin/bash shebang.
# Why this matters
1 | sh loop.sh |
This explicitly tells the shell to interpret the script using sh , ignoring the shebang line entirely. The shebang ( #!/bin/bash ) only takes effect when you execute the script directly (e.g. ./loop.sh ) or run it with bash loop.sh . When you invoke it as sh loop.sh , you’re overriding that and forcing sh to be the interpreter instead.
On most modern Linux systems (Debian/Ubuntu especially), /bin/sh is not actually a copy of bash — it’s a symlink to dash, a minimal POSIX-compliant shell:
1 | ls -l /bin/sh |
# Why [[ fails under sh /dash
[[ ... ]] is a bash-specific extended test construct — it’s not part of the POSIX shell standard. Dash only understands the POSIX single-bracket test [ ... ] (which is really just the test command). Since dash doesn’t recognize [[ as a keyword, it tries to treat it as a regular command name to execute — and fails with:
1 | loop.sh: 3: [[: not found |
That’s dash saying “I don’t have a command called [[ .”
# How to fix it
Option 1 — Run it with bash directly:
1 | bash loop.sh |
This respects the script’s use of bash-specific syntax.
Option 2 — Make it executable and run it directly (uses the shebang):
1 | chmod +x loop.sh |
This way, the kernel reads #!/bin/bash and invokes bash automatically, regardless of what your login shell or sh is.
Option 3 — Rewrite for POSIX compliance if you truly need sh portability:
1 |
|
Here [[ ]] is replaced by POSIX [ ] , and (( i += 1 )) (also a bash/ksh extension) is replaced with POSIX arithmetic expansion i=$((i + 1)) .
# Quick summary
| Command | Interpreter used | Result |
|---|---|---|
sh loop.sh |
dash (or whatever /bin/sh points to) |
Fails — [[ not supported |
bash loop.sh |
bash | Works |
./loop.sh (after chmod +x ) |
bash (via shebang) | Works |
# Q8: When do I need to use () , {} , and [] ?
1 | cat array.sh |
1 | array=("value1" "value2" "value3") |
1 | cat function.sh |
1 | add() { |
Answer:
These three symbols look similar but serve completely different purposes in bash.
# () — Parentheses
1. Declaring an array
1 | array=("value1" "value2" "value3") |
Parentheses here define an array literal — the only way to assign multiple values to a variable at once in bash.
2. Defining a function
1 | add() { |
The () after a name marks it as a function definition — always empty in bash. Arguments are passed positionally instead ( $1 , $2 , etc., inside the body).
3. Command substitution — $(...)
1 | local sum=$(($1 + $2)) |
$(...) runs a command (or expression) and captures its output as a string.
$(add 5 3)→ runs theaddfunction, captures whatever itechos.$(( ... ))→ arithmetic expansion. Anything inside is evaluated as math, not run as a command.
4. Subshells
1 | (cd /tmp && ls) |
Plain ( ) around commands (no $ ) runs them in a subshell — a child process with its own environment, so cd or variable changes inside don’t affect the current shell.
# {} — Curly braces
1. Function body
1 | add() { |
{ } groups the commands that make up the function — block delimiting.
2. Parameter/variable expansion
1 | echo ${array[0]} |
${ } is required whenever you need more than a bare variable name substitution — here, indexing into an array. Without the braces, $array[0] wouldn’t work as expected (bash would expand $array and treat [0] as literal text).
Other common ${} cases:
1 | ${var:-default} # use default if var is unset |
3. Brace expansion
1 | echo file{1,2,3}.txt # → file1.txt file2.txt file3.txt |
A generation shortcut, unrelated to variables.
# [] — Square brackets
1. Array indexing
1 | echo ${array[0]} # value1 |
2. Test conditions
1 | if [ "$sum" -gt 10 ]; then # POSIX test command |
# Quick reference table with examples
| Symbol | Context | Meaning | Example |
|---|---|---|---|
( ) |
name=(...) |
Array literal | array=("a" "b" "c") |
( ) |
name() { } |
Function definition marker | greet() { echo hi; } |
$( ) |
$(command) |
Command substitution (capture output) | files=$(ls) |
$(( )) |
$((expr)) |
Arithmetic evaluation | sum=$((5 + 3)) → 8 |
( ) |
(cmd1; cmd2) |
Subshell (runs in child process) | (cd /tmp && ls) |
{ } |
name() { ... } |
Function body / command block | add() { echo $(($1+$2)); } |
${ } |
${var} , ${var[0]} |
Parameter expansion (indexing, defaults, editing) | echo ${array[0]} → a |
{ } |
{1,2,3} , {a..z} |
Brace expansion | echo file{1,2}.txt → file1.txt file2.txt |
[ ] |
inside ${array[0]} |
Array index | echo ${array[1]} → b |
[ ] |
if [ cond ] |
POSIX test command | if [ "$sum" -gt 5 ]; then echo big; fi |
[[ ]] |
if [[ cond ]] |
Bash extended test | if [[ $sum -gt 5 && $sum -lt 20 ]]; then echo ok; fi |
Example outputs:
1 | array=("a" "b" "c") |
# Q9: (cmd1; cmd2) subshell example
Answer:
1 | pwd |
What’s happening:
The ( ) runs cd /tmp && pwd in a subshell — a separate child process that inherits the current environment but can’t change it. Once the subshell exits, any cd , variable assignments, or exit calls made inside it disappear — the original shell is untouched.
Compare with no parentheses (same shell):
1 | pwd |
Another common use — isolating variables:
1 | x=10 |
Why this matters in practice:
- Safe to
cdaround and explore without messing up your script’s working directory. - Useful for grouping commands whose side effects (variables, directory changes,
exit) you want contained. - Trade-off: subshells cost a bit of overhead (forking a process), and any variables set inside are lost once it exits — you can’t pass data back out except via output (stdout) or exit status.
# Q10: Fix the program
1 | echo $(pwd) |
Answer — fixed version:
1 | echo $(pwd) |
What was wrong:
1 | echo (cd ~ && pwd) # ✗ syntax error |
(cd ~ && pwd) starts a subshell, but a subshell must stand alone as a command — it can’t be passed as an argument to echo . Bash sees ( right after echo and doesn’t know what to do with it.
The fix: add $ in front to make it command substitution, so the subshell’s output gets captured and passed to echo as a string:
1 | echo $(cd ~ && pwd) # ✓ works |
Running the corrected script:
1 | echo $(pwd) |
Why the last line matches the first line:
$(cd ~ && pwd) runs cd ~ && pwd inside a subshell (implied by the parentheses even under command substitution — $() always runs in a subshell). The cd only affects that subshell’s environment. Once the subshell finishes and its output is captured, the actual shell’s working directory is untouched — so the third pwd still reports the original directory.
Alternative fix — if you actually wanted cd to persist, drop the subshell entirely:
1 | echo $(pwd) |
This would show the directory actually changing, since there’s no subshell isolating the cd .
# Q11: Remove the first-level Rust target dir recursively under a given base dir
Answer:
To delete every top-level target/ directory found under a base directory — without descending into a target/ once found — use find with -prune :
1 | find /path/to/base -type d -name target -prune -exec rm -rf {} + |
Breakdown:
| Part | Meaning |
|---|---|
find /path/to/base |
start searching from the base directory |
-type d |
only match directories |
-name target |
match directories named exactly target |
-prune |
don’t descend into it once matched — stops find from recursing inside a matched target dir (avoids wasting time scanning gigabytes of build artifacts, and avoids matching nested target dirs inside dependency source trees) |
-exec rm -rf {} + |
delete each matched directory (and everything inside it) |
Dry run first (recommended):
1 | find /path/to/base -type d -name target -prune -print |
Example:
1 | find ~/rust-projects -type d -name target -prune -print |
Why -prune matters: without it, find would still try to descend into each matched target directory before deleting it, wasting time walking huge build directories and occasionally causing “No such file or directory” warnings if a subdirectory gets deleted mid-traversal.
Alternative — using cargo clean per project (if these are real Cargo projects):
1 | find /path/to/base -type f -name Cargo.toml -execdir cargo clean \; |
Safer since it uses Cargo’s own logic rather than a blunt rm -rf .
# Q12: The meaning of {} +
1 | find /path/to/base -type d -name target -exec rm -rf {} + |
Answer:
# {} — placeholder for the matched file/directory
{} is a placeholder that find substitutes with each path it matches. For every directory named target that find discovers, {} gets replaced with that directory’s actual path.
# + vs ; — how the command gets executed
\; — run the command once per match:
1 | find /path/to/base -type d -name target -exec rm -rf {} \; |
Runs rm -rf separately for every single match:
1 | rm -rf /path/to/base/app1/target |
One process per match — slow if there are many matches.
+ — batch multiple matches into one command:
1 | find /path/to/base -type d -name target -exec rm -rf {} + |
Collects as many matched paths as will fit on one command line and runs rm -rf once (or as few times as possible):
1 | rm -rf /path/to/base/app1/target /path/to/base/app2/target /path/to/base/app3/target |
Much more efficient — far fewer process spawns.
Key syntax rule: with + , the {} must appear exactly once, right before the + , and must be the last argument in the -exec clause.
# Side-by-side comparison
| Form | Behavior | Efficiency |
|---|---|---|
-exec rm -rf {} \; |
One rm -rf process per match |
Slower — N processes for N matches |
-exec rm -rf {} + |
One rm -rf process for many matches (batched) |
Faster — few processes total |
# Why it’s safe to batch here
Since -prune already stops find from descending into a matched target directory, each match is a self-contained top-level directory to delete — so batching them into a single rm -rf a b c call is safe.
# Quick mnemonic
{}= “put the matched path(es) here”\;= “run me once per match” (semicolon must be escaped since;is special to the shell)+= “collect matches and run me once, in bulk” (no escaping needed)
# Q13: The meaning of -execdir
1 | find /path/to/base -type f -name Cargo.toml -execdir cargo clean \; |
Answer:
-execdir is a variant of -exec that changes where the command runs before executing it.
# The core difference: -exec vs -execdir
1 | find /path/to/base -type f -name Cargo.toml -exec cargo clean \; |
With -exec , the command runs from find 's original starting directory, and {} gives the path to the matched file.
1 | find /path/to/base -type f -name Cargo.toml -execdir cargo clean \; |
With -execdir , find first changes into the directory containing the matched file, then runs the command there.
# Why this matters for cargo clean
cargo clean doesn’t take a path argument — it operates on whatever Cargo project is in the current working directory. So for a match like /path/to/base/app1/Cargo.toml , -execdir does the equivalent of:
1 | cd /path/to/base/app1 |
So cargo clean runs inside app1/ , finds Cargo.toml right there, and cleans that project’s target/ correctly.
With plain -exec , cargo clean would run from wherever find was originally invoked — not from inside app1/ — so it wouldn’t know which project to clean.
# Side-by-side
| Form | Working directory when command runs |
|---|---|
-exec cmd {} \; |
Wherever find was originally run from |
-execdir cmd \; |
The directory containing the matched file |
# Bonus: safety note
-execdir is also considered slightly safer than -exec for security-sensitive scripts, since it avoids constructing paths that could be manipulated (e.g. via symlink races) between the time find locates a file and the time the command actually runs.
# Quick mnemonic
-exec→ “run this command, and here’s the path to the match” (path passed as argument)-execdir→ “cd into the match’s directory first, then run this command” (no path argument typically needed)
# Q14: The speed of cargo clean compared to rm
Answer:
# rm -rf target — faster raw deletion
1 | rm -rf target |
This is a pure filesystem operation: rm walks the directory tree and unlinks every file and directory entry. No parsing, no logic — just syscalls ( unlink , rmdir ) as fast as the filesystem and kernel can process them.
Speed characteristics:
- Bottlenecked almost entirely by disk I/O and filesystem overhead (especially with huge numbers of small files — Rust build artifacts can easily be tens of thousands of
.rlib,.o,.d, and incremental compilation files). - No understanding of what it’s deleting — just brute-force removal.
- Generally the fastest way to nuke a directory.
# cargo clean — slightly slower, but smarter
1 | cargo clean |
Internally does something close to rm -rf target/ too — but goes through Cargo’s own logic first:
- Parses
Cargo.toml/Cargo.lockto resolve the workspace and target directory location (which might not just be./target— could be overridden viaCARGO_TARGET_DIR, workspace config, etc.). - Determines exactly what belongs to the build output.
- Then performs the deletion (in newer Cargo versions, sometimes selectively — e.g.
cargo clean -p <package>only removes artifacts for one crate).
This adds measurable startup overhead: Cargo has to invoke toolchain machinery, parse manifests, resolve dependency graphs, etc. — even for a full clean, this costs real time (often 0.1–1+ seconds) before deletion even starts.
# Rough comparison
| Method | Speed | What it does |
|---|---|---|
rm -rf target |
Fastest | Pure deletion, no parsing overhead |
cargo clean |
Slightly slower | Parses project metadata first, then deletes |
cargo clean -p <pkg> |
Slower still (per-package) | More selective, but requires dependency graph resolution |
# Why you might still prefer cargo clean despite being slower
- Correctness: if
CARGO_TARGET_DIRis set to something other than./target, or it’s a workspace with a shared target dir, blindrm -rf targetmight miss the real location. - Selectivity:
cargo clean -p somecrateorcargo clean --releaselets you clean only part of the build output. - Safety: less risk of a typo turning into a disaster, since Cargo only ever touches paths it resolved itself.
# Practical takeaway
For bulk-cleaning many Rust projects under a base directory (deleting the whole target/ every time, not doing selective cleans):
1 | find /path/to/base -type d -name target -prune -exec rm -rf {} + |
will almost always be faster overall at scale — no per-project Cargo startup/parsing cost, and batched via + into a small number of rm process spawns instead of one cargo clean invocation per project.
# Q15: Why does find -execdir cargo clean always output “No such file or directory”?
1 | $ find /d/study/rust -type f -name Cargo.toml -execdir cargo clean \; |
Answer:
This is a harmless race between find 's directory-tree traversal and the side effect of cargo clean deleting things out from under it — not an actual failure of your command.
# What’s happening
When find scans a directory, it first reads all entries in that directory (via readdir ) — including Cargo.toml , target/ , src/ , etc. — and queues them up for further processing/traversal.
Then, as find processes the Cargo.toml entry it already queued, it triggers -execdir cargo clean , which deletes the target/ directory right then and there.
But find had already queued target/ (from the same readdir listing) to descend into later, since find normally recurses into every subdirectory it discovers. When it later tries to stat /enter that target/ directory to continue its traversal — it’s already gone, because cargo clean just deleted it.
Hence:
1 | find: ‘/d/study/rust/advance/channel/target’: No such file or directory |
find is just complaining that a directory it planned to visit vanished before it got there — which is expected, since that’s exactly the goal of running cargo clean on that project.
# Why it’s harmless
cargo clean's “Removed N files, X MiB total” output confirms the deletion succeeded.- The error is purely
findbeing surprised that a directory it queued for traversal no longer exists. - Order of operations:
readdir(liststarget/) →-execdirrunscargo clean(deletestarget/) →findlater tries to descend into the now-deletedtarget/→ error.
# How to confirm nothing’s actually wrong
1 | find /d/study/rust -type d -name target |
If this returns nothing (or only un-cleaned projects), all target/ dirs were successfully removed despite the cosmetic warnings.
# How to suppress the noise (optional)
1 | find /d/study/rust -type f -name Cargo.toml -execdir cargo clean \; 2>/dev/null |
# Why rm -rf via -prune doesn’t have this problem
1 | find /path/to/base -type d -name target -prune -exec rm -rf {} + |
Because -prune stops find from ever queuing traversal into target/ in the first place, there’s no leftover “planned descent” for find to later fail on. rm -rf deletes target/ itself as a leaf match, and find never tries to look inside it — so no such warning appears with that version.
# Q16: Explain this bash code
1 | file="report.tar.gz" |
This is bash parameter expansion for trimming strings — no sed / cut needed.
# The four operators
| Operator | Removes | From | Match style |
|---|---|---|---|
${var%pattern} |
suffix | end | shortest |
${var%%pattern} |
suffix | end | longest |
${var#pattern} |
prefix | start | shortest |
${var##pattern} |
prefix | start | longest |
Mnemonic: # strips from the left (prefix); % strips from the right (suffix). Doubling the symbol ( ## , %% ) means “be greedy.”
# Line by line
${file%.gz} → report.tar
% strips a suffix, matching the shortest string that fits the pattern .gz . Since .gz only matches the literal end, it just chops that off, leaving report.tar .
${file%%.*} → report
%% strips a suffix too, but greedily — it matches the longest possible string fitting .* (a dot followed by anything) working from the end. The longest match starting from the first dot is .tar.gz , so everything from the first . onward gets removed, leaving report .
${file#*.} → tar.gz
# strips a prefix, matching the shortest string fitting *. (anything, then a dot) from the start. The shortest such match is report. , so removing it leaves tar.gz .
(If you wanted just the final extension gz , you’d use ${file##*.} — greedy prefix strip, removing everything up through the last dot.)
# Why this matters
No subprocess is spawned (unlike basename , cut , or sed ), so it’s fast and portable across POSIX-compliant shells. This pattern is the standard idiom for splitting filenames into name/extension pieces in shell scripts.
# Q17: Remove the leading tab and space from this heredoc
1 | cat <<- EOF |
# What <<- actually does
The <<- variant of a heredoc strips leading tab characters only from each line, so you can indent the body to match your script’s indentation and still get clean output. It does not strip leading spaces.
If the leading whitespace before Usage: , Processes , and Run are spaces (common with space-indenting editors or copy-paste), <<- leaves them untouched. Output would look like:
1 | Usage: myscript [options] <file> |
with the indentation still sitting there.
# How to actually remove it
Option 1 — Use actual tab characters for the indent, then <<- works as intended:
1 | cat <<-EOF |
(Each line above starts with a literal tab character.)
Option 2 — Don’t indent the body at all — drop <<- for a plain << and left-align the content:
1 | cat <<EOF |
This is the more common, less fragile approach since it doesn’t depend on tabs-vs-spaces at all — mixing tabs and spaces in indentation is a classic source of “why isn’t this stripping?” bugs.
# Bottom line
<<- strips leading tabs, never spaces. If your source has spaces, switch to plain << and remove the indentation manually, or convert the leading whitespace to real tabs.
# Q18: Is this bash code valid?
1 | n=5 |
A: Yes, that’s valid bash. It will print:
1 | 1 |
seq 1 "$n" generates the numbers 1 through 5 (since n=5 ), and the loop echoes each one on its own line. The quotes around "$n" aren’t strictly necessary here since $n is a simple number with no spaces, but they don’t cause any problems either — just a defensive habit some people use.
# Q19: Is this explanation wrong?
1 | for i in $(seq 1 "$n"); do # seq when the bound is a VARIABLE ({1..$n} does NOT expand) |
A: That explanation is actually correct, not wrong.
Here’s why: {1..$n} brace expansion happens before variable expansion in bash’s parsing order. So by the time bash tries to expand {1..$n} , it doesn’t know $n is 5 yet — it just sees the literal text {1..$n} and can’t expand it as a range. You’d get literal output like {1..5} printed once, not a sequence.
You can verify this:
1 | n=5 |
Output:
1 | {1..5} |
That’s why seq 1 "$n" (or $(seq 1 "$n") ) is the correct approach when the bound is a variable — seq is a command, and commands do get their arguments after variable expansion happens. So the comment is pointing out a real and common gotcha: brace expansion with variables doesn’t work the way people expect, while seq (or a C-style for ((i=1; i<=n; i++)) loop) handles variables fine.
So to summarize: the comment is accurate. {1..$n} does not expand as a range when n is a variable, which is exactly why the code uses seq instead.
# Q20: Explain this simply
1 | while read -r line; do |
This loop consumes stdin until it runs out — the pattern for processing files and piped data (-r stops backslash-mangling; write it always). It matters because the alternative people invent —
for line in $(cat file)— is wrong: unquoted expansion splits on all whitespace, so lines with spaces shatter into words. Lines wantwhile read -r; words wantfor. Carve that distinction in; it’s the difference between a script that works and one that works on your test file.
A:
The good pattern:
1 | while read -r line; do |
This reads input one whole line at a time, no matter what’s in that line — spaces, tabs, whatever. Each loop iteration, $line = one complete line.
-rjust means “don’t treat backslashes specially” (e.g. don’t mangle\nor\tin the text). You should basically always include it.
The bad pattern people try instead:
1 | for line in $(cat file); do |
This looks like it should do the same thing, but it doesn’t. Here’s the problem: $(cat file) dumps the whole file as one blob of text, and when it’s unquoted like this, bash splits that blob on any whitespace — spaces, tabs, newlines, all treated the same. It doesn’t care about line breaks specifically.
Concrete example. Say your file has:
1 | hello world |
With while read -r line , you get:
1 | got: hello world |
Two iterations, one per line. Correct.
With for line in $(cat file) , you get:
1 | got: hello |
Three iterations — it split “hello world” into two separate words because there’s a space in it. The line got shattered.
The takeaway: if you want to process a file line by line, use while read -r . The for x in $(...) pattern is for looping over separate words (like filenames with no spaces), not lines. It only looks like it works if you test on a file where no line happens to contain a space — that’s the trap.
# Q21: Give an example for -r meaning “don’t treat backslashes specially”
A:
Here’s a concrete example. Say your file contains this literal line (with an actual backslash-n in the text, not a real newline):
1 | path\to\nfile |
Without -r :
1 | while read line; do |
Output:
1 | got: pathtonfile |
The backslashes got eaten. read (without -r ) treats \ as an escape character, so \t , \n , \f etc. get stripped or interpreted, and the backslash itself disappears from the output.
With -r :
1 | while read -r line; do |
Output:
1 | got: path\to\nfile |
The backslashes are preserved exactly as they appeared in the input — “raw” mode, which is what -r stands for.
Why this matters in practice: this bites people most often with Windows-style file paths ( C:\Users\name ) or any text containing backslashes — log files, regex patterns, escaped strings. Without -r , read silently corrupts that data. That’s why the rule of thumb is: just always type -r , every time, out of habit — there’s basically never a reason not to want raw input.
#
# Q22: The difference of ${arr[@]} and ${arr[*]}
Short version:
${arr[@]}— expands each array element as a separate word${arr[*]}— expands all elements as one single word, joined by the first character ofIFS(usually a space)
They look identical when unquoted, but behave very differently when quoted.
# Example
1 | arr=("hello world" "foo" "bar baz") |
Unquoted — behave the same (both split on spaces, losing structure):
1 | for x in ${arr[@]}; do echo "[$x]"; done |
Quoted — this is where it matters:
1 | for x in "${arr[@]}"; do echo "[$x]"; done |
Each element stays intact as its own word — this is what you almost always want.
1 | for x in "${arr[*]}"; do echo "[$x]"; done |
All elements get mashed into a single string, separated by spaces (or whatever IFS is set to).
# Rule of thumb
- Use
"${arr[@]}"(quoted, with@) when looping over an array or passing elements as separate arguments — this is the correct default 99% of the time. - Use
"${arr[*]}"only when you deliberately want to flatten the array into one string (e.g., for printing a summary like"Items: ${arr[*]}").
# Same distinction applies to $@ vs $*
For positional parameters (script/function arguments), it’s identical:
1 | "$@" # each argument preserved separately (safe for loops, passing args) |
# Q23: Syntax for an associative array (bash 4+)
Associative arrays (bash 4+) let you use strings as keys instead of integers.
# Declaring
You must explicitly declare it with -A (this is required — without it, bash treats it as a normal indexed array):
1 | declare -A arr |
# Populating
One at a time:
1 | arr[name]="Alice" |
All at once:
1 | declare -A arr=( [name]="Alice" [age]=30 [city]="NYC" ) |
# Accessing values
1 | echo "${arr[name]}" # Alice |
# Getting all keys / all values
1 | echo "${!arr[@]}" # name age city (the keys) |
# Looping over it (the common pattern)
1 | for key in "${!arr[@]}"; do |
# Other useful bits
1 | ${#arr[@]} # number of elements |
# Common gotchas
declare -Ais required —arr=([foo]=bar)without it silently creates a broken indexed array (using0as the numeric interpretation offoo… unpredictable).- Associative arrays are not ordered — don’t rely on key order when looping.
- They’re not exportable to subshells/environment (unlike normal variables), and can’t be passed to a script as environment variables.
- Quote your keys/values if they contain spaces or special characters:
arr["my key"]="my value".
# Q24: Fix the bash code
Original (broken):
1 | if cd /nope then |
Problem: Missing a ; (or newline) between the command and then . Bash needs a statement terminator there.
Fixed:
1 | if cd /nope; then |
Logic note: Since /nope almost certainly doesn’t exist, cd /nope will fail (exit status non-zero), so the if condition is false and "ok" will not print. If you want to handle the failure case, you probably want:
1 | if cd /nope; then |
Or if you just want to attempt the cd and bail out on failure (a common script pattern):
1 | cd /nope || { echo "cd failed"; exit 1; } |
# Q25: Explain nums=($line)
1 | read line |
nums=($line) builds an indexed array by taking the value of $line and splitting it into separate words — one array element per word.
# How it works
1 | line="10 20 30" |
Because $line is unquoted inside the parentheses, bash performs:
- Word-splitting — the string is split wherever
IFScharacters occur (default: space, tab, newline) - Pathname expansion (globbing) — if any resulting word contains
*,?, etc., bash will try to expand it against filenames
That second point is the real hazard.
# Why this is fragile
1 | line="file1 * file2" |
Also, multiple consecutive spaces or irregular whitespace get squashed together, and it doesn’t respect quoted substrings within $line (there’s no re-parsing of quotes — just plain splitting).
# Safer modern alternative
If you actually want to split a string into an array in a predictable way, prefer read -a :
1 | read -a nums <<< "$line" |
Or if globbing is a concern with the original approach, disable it temporarily:
1 | set -f # disable globbing |
# Summary
| Piece | Meaning |
|---|---|
($line) |
Unquoted expansion inside array literal → triggers word-splitting + globbing |
| Result | Each “word” from $line becomes one array element |
| Risk | Special glob characters ( * , ? , [...] ) in $line can expand unexpectedly if matching files exist |
So the comment # word-splitting fills the array is accurate — that’s exactly the mechanism — but it’s worth knowing it’s the same mechanism that can bite you with globbing.
# Q26: read -r — do not allow backslashes to escape any characters
read -r disables backslash escaping during the read — backslashes are treated as literal characters instead of escape characters.
# Without -r (default behavior)
By default, read treats a trailing backslash as a line-continuation character, and backslashes elsewhere as escapes that get stripped.
1 | $ echo 'C:\new\test' | (read line; echo "$line") |
Notice the backslashes vanished — \n and \t weren’t interpreted as escape sequences, but the backslash itself was consumed/stripped.
The line-continuation trap:
1 | $ printf 'first\\\nsecond\n' | (read line; echo "[$line]") |
A trailing \ at the end of a line tells read “this continues onto the next line” — so two lines get silently joined into one.
# With -r (recommended)
1 | $ echo 'C:\new\test' | (read -r line; echo "$line") |
Backslashes are preserved exactly as typed. No stripping, no line-continuation magic.
# Real-world example: reading file paths
1 | # Without -r — breaks on Windows-style or backslash-containing paths |
# Real-world example: reading lines from a file
This is the classic pattern, and -r is considered essential here:
1 | while IFS= read -r line; do |
IFS=— prevents leading/trailing whitespace from being trimmedread -r— prevents backslashes in the file from being mangled or causing accidental line-joining
If myfile.txt contains a line ending in \ , or file paths, regex patterns, or Windows paths with backslashes, omitting -r will silently corrupt your data.
# Rule of thumb
Always use read -r unless you specifically want backslash-escape processing (which is rare). Shellcheck (the popular bash linter) will actually warn you if you use read without -r , because the plain form is considered an easy-to-hit footgun.
# Q27: Why need shift $((OPTIND - 1)) ?
1 |
|
getopts doesn’t remove the options it’s already parsed from $@ — it just tracks progress internally using the variable OPTIND . shift $((OPTIND - 1)) is what actually removes the parsed options, leaving only the non-option arguments behind in $@ /positional parameters.
# What OPTIND is
OPTIND is a counter getopts maintains, pointing to the index of the next argument to process. After the loop finishes consuming all recognized options, OPTIND holds the index of the first non-option argument.
# Walkthrough example
1 | ./script.sh -v -f config.txt input1.txt input2.txt |
Positional args before any shifting:
1 | $1 = -v |
As getopts runs through the while loop:
| Step | Consumes | OPTIND becomes |
|---|---|---|
Parses -v |
$1 |
2 |
Parses -f config.txt |
$2 , $3 |
4 |
| No more options found | — | stays 4 |
So after the loop, OPTIND=4 — meaning “argument #4 ( input1.txt ) is the first real positional argument.”
# Why shift is needed
getopts only reads $@ — it never modifies it. Without shifting, $@ would still be:
1 | -v -f config.txt input1.txt input2.txt |
even though you already extracted verbose and file from those tokens. If you tried to use $@ for “remaining files,” you’d get garbage — the options would still be sitting in there.
shift $((OPTIND - 1)) shifts away the first OPTIND - 1 arguments (i.e., everything getopts already consumed), leaving:
1 | $1 = input1.txt |
# Testing it
1 | $ ./script.sh -v -f config.txt input1.txt input2.txt |
Without the shift line, remaining="$@" would instead print:
1 | remaining=-v -f config.txt input1.txt input2.txt |
— the whole original argument list, options included, which defeats the purpose of separating “options” from “operands” (the actual files/arguments the script should act on).
# Rule of thumb
Any time you use getopts in a loop and want to reference “everything after the options” (very common — e.g. a list of filenames after -v / -f flags), you need shift $((OPTIND - 1)) right after the loop. It’s boilerplate, but necessary — getopts parses, shift cleans up.
# Q28: Explain the nameref code
1 | populate() { |
This code uses a nameref ( local -n ) — a bash 4.3+ feature that lets a variable act as a reference/alias to another variable, whose name is passed in as a string. This is how bash simulates “pass by reference” for functions.
# Breaking it down
| Line | What happens |
|---|---|
populate myarr |
Calls the function, passing the string "myarr" (not the variable itself) as $1 |
local -n out=$1 |
Declares out as a local nameref bound to whatever variable name is stored in $1 — here, that’s myarr |
out=(a b c) |
Assigning to out actually assigns to myarr , because out is just an alias for it |
After the function runs, myarr (not out ) contains (a b c) , even though myarr was never directly mentioned inside the function body.
1 | populate myarr |
# Why this matters — the problem it solves
Without namerefs, if a function tries to build/modify an array, it can only affect its own local copy or global variables — there’s no clean way to say “modify the array whose name the caller gave me.”
The old/hacky way (before namerefs), using eval :
1 | populate() { |
This works but is fragile and dangerous ( eval executes arbitrary strings — a classic injection risk if $1 isn’t controlled).
With -n , it’s clean and safe:
1 | populate() { |
# A more realistic example
1 | fill_with_squares() { |
Here, result inside the function is just a pointer to whatever array name gets passed in ( squares ), so the function can build the array in-place — as if it had “return-by-reference” semantics.
# Important gotchas
-
Name collision: if you call
populate out(i.e., pass the literal name"out"), you get a self-reference error —outcan’t be a nameref to itself. Pick internal variable names unlikely to clash with caller variable names (a common convention: prefix internal names, e.g._out,__ref).1
2
3populate out
# bash: out: circular name reference -
Requires bash 4.3+ (check with
bash --version; won’t work on macOS’s default ancient bash 3.2 unless the user has installed a newer bash via Homebrew). -
local -nonly creates the nameref for the current function scope — it disappears when the function returns, but its effect on the referenced variable persists.
# Q29: cat file | cmd — useless cat
This refers to the “Useless Use of Cat” (UUOC) — a well-known shell scripting antipattern. It’s not wrong, just wasteful: it spawns an extra process and adds an unnecessary pipe when a simple redirection would do the same job more efficiently.
# The pattern
1 | cat file | cmd |
cat reads file and writes its contents to stdout, which then gets piped into cmd 's stdin. That works — but it means:
- Bash forks a whole separate process just to shuffle bytes from a file into a pipe
- Data has to flow: disk → cat’s stdout → pipe buffer → cmd’s stdin, instead of
cmdreading the file directly
# The better way
1 | cmd < file |
or equivalently:
1 | < file cmd |
Both mean: redirect file directly to cmd 's stdin — no intermediate process, no pipe. Bash just opens the file descriptor and hands it to cmd .
( < file cmd and cmd < file are functionally identical — redirections can appear anywhere in the command, even before the command name. Most people write it at the end out of habit/readability.)
# Concrete example
1 | # Wasteful — spawns cat unnecessarily |
Here, grep (like most standard tools — sort , wc , awk , sed , grep , etc.) already knows how to open and read a file directly. Piping through cat adds nothing.
# When cat | cmd is NOT useless
There are legitimate cases where cat is doing real work, and this critique doesn’t apply:
Concatenating multiple files:
1 | cat file1 file2 file3 | grep "ERROR" |
This is fine — cat is doing its actual job (concatenation), and most commands can’t take multiple files with implicit concatenation the same way.
The command genuinely can’t take a filename argument (rare, but some tools only read stdin):
1 | cat file | some_stdin_only_tool |
Here you could still avoid cat via redirection:
1 | some_stdin_only_tool < file |
so honestly, even in this case, redirection usually wins.
# Why people still write cat file | cmd
Mostly habit and readability — reading left-to-right (“take this file, then do X”) can feel more intuitive than redirection syntax, especially in longer pipelines:
1 | cat data.csv | cut -d, -f1 | sort | uniq -c |
vs.
1 | cut -d, -f1 < data.csv | sort | uniq -c |
Functionally, for a single file at the start of a pipeline, the second is strictly better (one less process, one less pipe) — but the first is such a common idiom that shellcheck flags it (SC2002) mainly as a style/efficiency nit, not a bug.
# Rule of thumb
If cat is being used on exactly one file just to feed it into the next command’s stdin, replace it with < file redirection. Save cat for when you’re actually concatenating multiple files or displaying content to a terminal.
# Q30: The difference between <(ls) , (ls) , and ls
1 | count=0 |
# < <(ls) — process substitution
<(ls) is process substitution. Bash runs ls in the background, connects its output to a temporary file-like object (usually /dev/fd/N under the hood), and the outer < redirects that as stdin to the while loop.
Key property: the while loop runs in the current shell — no subshell for the loop itself. So count is a real variable in your script, and echo "$count" afterward sees the correct value.
1 | count=0 |
This is the standard fix for the classic “pipe creates a subshell, so my variable resets to empty after the loop” problem.
# | (ls) or piping into a loop — subshell problem
If you’d written it as a pipe instead:
1 | count=0 |
In bash, each side of a pipe runs in its own subshell. The while loop executes in a child process, so count gets incremented inside that subshell — but when the subshell exits, its copy of count is discarded. The parent shell’s count was never touched, so it prints 0 .
(ls) on its own (wrapped in parens) explicitly creates a subshell too:
1 | (ls) |
This just runs ls in a subshell — its output goes to the terminal as normal, but any variable assignments made in there don’t survive outside the parens. Not directly what you’re comparing, but same underlying concept: parentheses = subshell = variables don’t leak out.
# ls alone
1 | ls |
Just runs the command directly in the current shell, printing to stdout. No redirection, no substitution, no subshell for ls itself (though ls is a separate process regardless — every external command is). If you tried:
1 | while read -r f; do (( count++ )); done < ls |
That would actually be a syntax/runtime error — < ls tries to redirect stdin from a file literally named ls in the current directory, not from the command ls . Bash doesn’t run ls here at all; it just tries to open a file called ls .
# Summary table
| Form | What happens | Loop runs in | Variables persist? |
|---|---|---|---|
< <(ls) |
Process substitution — ls 's output fed as a file-like stdin |
Current shell | ✅ Yes |
ls \| while ... |
Pipe — output piped into loop | Subshell | ❌ No |
(ls) |
Subshell — ls runs inside () |
N/A (just runs ls ) |
❌ No (for anything assigned inside) |
< ls |
Tries to open a file named ls |
— | Error if no such file exists |
ls (bare) |
Runs the command normally | Current shell | N/A (no loop) |
# Why this specific idiom is popular
while read -r line; do ... done < <(command) is the idiomatic way to loop over command output while keeping the ability to modify variables that persist after the loop — solving the “pipeline subshell” gotcha that trips up almost everyone learning bash loops.
# Q31: Unquoted wildcards — find . -name *.txt
Wildcards UNQUOTED —
find . -name *.txtexpands the glob in the CURRENT directory before find sees it. Always quote:find . -name '*.txt'.
This is about shell glob expansion happening before find ever runs — a classic footgun.
# The core issue: order of operations
The shell (bash) expands wildcards like *.txt before it invokes any command. find never even sees the literal string *.txt unless you protect it from the shell.
1 | find . -name *.txt |
Bash sees *.txt as an unquoted glob and tries to expand it against files in the current directory — before find runs at all. So this doesn’t mean “ask find to search for .txt files”; it means “let bash substitute *.txt with whatever .txt files exist right here, then run find with those literal names.”
# Walkthrough example
Say your current directory contains:
1 | notes.txt |
1 | find . -name *.txt |
Bash expands *.txt → notes.txt report.txt first, so the command that actually executes is:
1 | find . -name notes.txt report.txt |
But find -name only takes one pattern argument. find interprets report.txt as an unexpected extra argument (usually another path to search), giving you an error like:
1 | find: paths must precede expression: report.txt |
Or worse — if there’s only one matching file, it silently “works” but only searches for that one specific filename, not the general pattern, e.g.:
1 | find . -name notes.txt |
which will miss any .txt files in subdirectories that aren’t literally named notes.txt .
# The really sneaky failure mode
If no files match *.txt in the current directory, bash (by default) leaves the glob unexpanded and passes the literal string *.txt through — so it happens to work by accident:
1 | find . -name *.txt # "works" only because nothing in `.` matched, so bash left it alone |
This is why the bug is so insidious: it can appear to work fine in one directory and break mysteriously in another, depending on what files happen to exist in your current working directory at the time.
# The fix — quote it
1 | find . -name '*.txt' |
Single (or double) quotes tell bash: don’t touch this, pass it through literally. Now find receives the actual string *.txt and does its own internal pattern matching — which is what you wanted all along: recursively find any file matching *.txt anywhere under . .
1 | find . -name '*.txt' |
# Rule of thumb
Any time a wildcard is meant for the command itself to interpret (not for the shell), quote it:
1 | find . -name '*.txt' |
The general principle: the shell expands globs; if you want the receiving program to do its own matching, prevent the shell from touching it first — via single quotes, double quotes, or (situationally) backslash-escaping the wildcard character.
# Q32: Background process management
1 | ./long-running & |
Explanation:
./long-running &runs the program in the background so the shell doesn’t block.bg_pid=$!captures the PID of the most recently backgrounded process.trap "kill $bg_pid 2>/dev/null" EXITregisters a cleanup handler that fires on any script exit (normal, error, or interrupt) and kills the background process.2>/dev/nullsuppresses the harmless error if the process already exited. Since the trap string is double-quoted,$bg_pidis expanded immediately when the trap is registered, not when it fires.wait $bg_pidblocks until the background process finishes and propagates its exit status.
This pattern prevents the background job from becoming orphaned if the script is killed early. Even on normal completion, the EXIT trap still fires and tries to kill the (already-finished) process — hence the 2>/dev/null .
# Q33: Named pipe (FIFO) that hangs
1 | mkfifo /tmp/pipe |
Why it hangs: A FIFO’s open() blocks until both a reader and a writer are present. Since “Process A” and “Process B” are just comments — this is one sequential script — the echo ... > /tmp/pipe line blocks waiting for a reader that never arrives (because the read line hasn’t executed yet, and never will while echo is stuck).
Fixes:
Background one side:
1 | mkfifo /tmp/pipe |
Run each side in a separate process/terminal.
Open the FIFO read-write on one fd (avoids blocking entirely):
1 | mkfifo /tmp/pipe |
# Q34: Coprocess with bc
1 | coproc CALC { bc -l; } |
Explanation:
coproc CALC { bc -l; }startsbc -lin the background with two pipes:CALC[1](write end → coprocess stdin) andCALC[0](read end → coprocess stdout).$CALC_PIDholds its PID.echo "3 + 4" >&"${CALC[1]}"sends the expression tobc.read result <&"${CALC[0]}"reads the computed result (7) back.echo "$result"prints7.
Unlike a one-shot pipeline ( echo "3+4" | bc -l ), coproc keeps the process alive so you can send it multiple commands over time, preserving state — useful for interactive tools like bc , python3 -i , sqlite3 , etc.
Caveats: clean up file descriptors/PID when done; some programs buffer stdout when not attached to a terminal, which can cause read to hang (not an issue with bc -l , which flushes per line).
# Q35: Fork-per-iteration vs. pure bash loop
1 | # BAD — forks for every iteration |
What times shows: cumulative CPU time for the current shell and all its completed child processes, in the form:
1 | <shell user time> <shell system time> |
Given output:
1 | 0m0.015s 0m0.098s |
- Shell itself: 0.015s user, 0.098s system.
- All children combined: 0.430s user, 0.026s system.
The BAD loop forks echo , wc , and a subshell every iteration (~3000 forks total), which shows up as elevated system time (kernel overhead for fork / exec ). The GOOD loop does zero forks — ${#i} is pure in-process string-length expansion.
Correctness note: the two loops aren’t computing the same thing — echo "$i" | wc -c counts characters including the trailing newline echo adds, while ${#i} counts digit length. Use printf '%s' "$i" | wc -c if you want them equivalent.
Getting separate timings per loop: wrap each loop individually with time :
1 | echo "=== BAD loop ===" |
times alone only gives a cumulative total since shell start, so it can’t attribute cost to one loop vs. the other — time { ... } around each block gives independent real / user / sys numbers.
# Q36: Meaning of $$
1 | results=() |
$$ expands to the PID of the current shell — fixed for the entire life of the script, not per-iteration or per-background-job. So every temp file is named /tmp/p_<script_pid>_<host> , and ${host} is what actually makes each filename unique. ( $! would give the PID of the last backgrounded job, which is different from $$ .)
The { ... } group around the single ping ... & command is unnecessary — it doesn’t do anything the bare command with & wouldn’t already do. Braces matter when you want to background multiple commands together as one job.
# Q37: ${__LIB_LOG_SOURCED:-} include guard
1 | if [[ -n "${__LIB_LOG_SOURCED:-}" ]]; then return; fi |
${VAR:-default} is parameter expansion with a fallback: it expands to VAR 's value if set and non-empty, otherwise to default (here, empty string).
This is an include guard pattern (like C’s #ifndef ) to prevent a library script from being source d twice. The :- fallback specifically guards against set -u (nounset) killing the script when referencing a variable that hasn’t been set yet — it produces an empty string instead of an “unbound variable” error.
Logic: if __LIB_LOG_SOURCED is already set (meaning the file was sourced before), return early and skip re-defining functions; otherwise, set the flag and continue with the definitions.
Quick reference:
| Form | Meaning |
|---|---|
${VAR-default} |
Use default only if VAR is unset |
${VAR:-default} |
Use default if VAR is unset or empty |
${VAR:=default} |
Same as :- but also assigns default to VAR |
${VAR:+alt} |
Use alt if VAR is set and non-empty (opposite logic) |
# Q38: kill -TERM vs kill -9 ( KILL )
kill -TERM (SIGTERM, 15) — can be caught, handled, or ignored by the process; gives it a chance to clean up (flush buffers, release locks, remove temp files, etc.). This is the default signal sent by plain kill <pid> .
kill -9 / kill -KILL (SIGKILL, 9) — cannot be caught, blocked, or ignored; enforced by the kernel; terminates immediately with no cleanup. Used as a last resort for hung/unresponsive processes. Can leave corrupted files, stale locks, or orphaned children behind.
Common pattern — escalate from TERM to KILL:
1 | kill -TERM "$pid" |
( kill -0 sends no signal, just checks if the process exists / is signalable.)
Caveat: SIGKILL bypasses the process’s own exit code entirely, so trap ... EXIT or trap ... TERM handlers never run. A process stuck in uninterruptible I/O sleep (D state) can even be unkillable by SIGKILL until the I/O resolves.
# Q39: Call-stack trap and BASH_LINENO indexing
1 | on_error() { |
Why ${BASH_LINENO[$i-1]} instead of ${BASH_LINENO[$i]} :
BASH_LINENO[i] is the line number in the caller of FUNCNAME[i] where that function was invoked — i.e., it describes frame i+1 , not frame i . So the line currently executing inside frame i is stored at BASH_LINENO[i-1] .
Example call chain foo → bar → baz :
1 | FUNCNAME = ( baz bar foo main ) |
BASH_LINENO[0]= line wherebazwas called (insidebar)BASH_LINENO[1]= line wherebarwas called (insidefoo)BASH_LINENO[2]= line wherefoowas called (inside main)
To print " foo is currently at line X," you need the line where foo called bar — that’s BASH_LINENO[1] , i.e., index i - 1 for FUNCNAME[2] . Using BASH_LINENO[$i] directly would shift every frame’s reported line by one level, attributing the wrong line to the wrong function.
Edge case: at i = 0 , BASH_LINENO[-1] refers to the last array element (bash allows negative indices), not something meaningful — this script avoids relying on that by using $LINENO (passed as the trap’s first argument) for the innermost error line instead.