# Q1: Why does bash background_process.sh produce no output for jobs ?

1
cat background_process.sh
1
2
3
4
5
#!/bin/bash
# run sleep command for 300, 400, and 500 seconds
sleep 300 &
sleep 400 &
sleep 500 &
1
2
3
4
5
6
7
8
9
bash background_process.sh

jobs

sleep 300 &
[1] 66560

jobs
[1]+ Running sleep 300 &

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:

  1. bash background_process.sh starts a new, separate bash process (a child shell) to run the script.
  2. 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.
  3. The script finishes almost instantly (backgrounding doesn’t block), so the child bash process exits right after launching the three sleeps.
  4. 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’s jobs list. They keep running in the background as orphaned processes (reparented to init/systemd), just invisible to jobs .
  5. 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, so jobs immediately reports [1] 66560 and 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
2
3
source background_process.sh
# or
. 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 pkill isn’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]leep bracket trick avoids matching the grep command 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
2
3
4
5
6
7
pkill sleep                # kill all processes named "sleep"
pkill -9 sleep # force-kill all "sleep" processes
pkill -u alice sleep # kill "sleep" processes owned by user alice
pkill -f "sleep 300" # match the full command line, e.g. "sleep 300"
pkill -x sleep # match only exact process name "sleep" (not e.g. "sleeper")
pkill -e sleep # show which processes were killed
pkill -1 sshd # send SIGHUP to sshd processes

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:

  • pkill matches by regex by default (not exact string), so pkill sleep would also match a hypothetical process named sleeper .
  • Without -u , it can kill any process you have permission to kill, not just ones from your terminal/script.
  • Use -9 only 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:

  1. > all_output.txt → stdout now points to all_output.txt
  2. 2>&1 → stderr now points to wherever stdout currently points, which is all_output.txt

So both streams end up in the same file.

Result:
After running this command:

  • Nothing prints to your terminal.
  • all_output.txt is 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
2
ls non_existent_directory 2>1     # creates a file called "1" with the error in it (surprising!)
ls non_existent_directory 2>&1 # merges stderr into stdout's current destination (intended)

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
2
3
4
5
6
7
*/1 * * * * /bin/sh /root/date-script.sh
│ │ │ │ │
│ │ │ │ └── day of week (0-7)
│ │ │ └──── month (1-12)
│ │ └────── day of month (1-31)
│ └──────── hour (0-23)
└──────────── minute (0-59)

# */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:

  1. Clarity/habit — carrying over the step-value pattern from other fields (e.g. */5 for every 5 minutes), even when the step is 1 and thus redundant.
  2. 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.:

  • */5 in minutes → every 5 minutes (0, 5, 10, 15, …)
  • */2 in 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
2
3
4
5
6
#!/bin/bash
i=1
while [[ $i -le 10 ]] ; do
echo "$i"
(( i += 1 ))
done
1
2
sh loop.sh
loop.sh: 3: [[: not found

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
2
ls -l /bin/sh
# lrwxrwxrwx 1 root root 4 ... /bin/sh -> dash

# 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
2
chmod +x loop.sh
./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
2
3
4
5
6
#!/bin/sh
i=1
while [ "$i" -le 10 ]; do
echo "$i"
i=$((i + 1))
done

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
2
array=("value1" "value2" "value3")
echo ${array[0]}
1
cat function.sh
1
2
3
4
5
6
add() {
local sum=$(($1 + $2))
echo $sum
}
result=$(add 5 3)
echo "The sum is $result"

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
2
3
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
2
local sum=$(($1 + $2))
result=$(add 5 3)

$(...) runs a command (or expression) and captures its output as a string.

  • $(add 5 3) → runs the add function, captures whatever it echo s.
  • $(( ... ))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
2
3
4
add() {
local sum=$(($1 + $2))
echo $sum
}

{ } 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
2
3
4
${var:-default}   # use default if var is unset
${#array[@]} # length of array
${var%.txt} # strip suffix
${str^^} # uppercase

3. Brace expansion

1
2
echo file{1,2,3}.txt   # → file1.txt file2.txt file3.txt
mkdir -p dir/{a,b,c}

A generation shortcut, unrelated to variables.

# [] — Square brackets

1. Array indexing

1
2
3
echo ${array[0]}    # value1
echo ${array[1]} # value2
echo ${array[@]} # all elements

2. Test conditions

1
2
if [ "$sum" -gt 10 ]; then     # POSIX test command
if [[ $sum -gt 10 ]]; then # bash extended test (safer, more features, bash-only)

# 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}.txtfile1.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
array=("a" "b" "c")

echo ${array[0]} # a
echo ${array[@]} # a b c
echo ${#array[@]} # 3 (length)

sum=$((5 + 3))
echo $sum # 8

files=$(ls)
echo "$files"

echo file{1,2,3}.txt # file1.txt file2.txt file3.txt

if [ "$sum" -gt 5 ]; then echo "big"; fi # big
if [[ $sum -gt 5 && $sum -lt 20 ]]; then echo "ok"; fi # ok

# Q9: (cmd1; cmd2) subshell example

Answer:

1
2
3
4
5
6
7
8
pwd
# /home/user

(cd /tmp && pwd)
# /tmp

pwd
# /home/user ← unchanged! cd only affected the subshell

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
2
3
4
5
6
7
8
pwd
# /home/user

cd /tmp && pwd
# /tmp

pwd
# /tmp ← changed! no subshell, so cd persists

Another common use — isolating variables:

1
2
3
4
5
6
x=10
(x=20; echo "inside: $x")
# inside: 20

echo "outside: $x"
# outside: 10 ← original x is untouched

Why this matters in practice:

  • Safe to cd around 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
2
3
echo $(pwd)
echo (cd ~ && pwd)
echo $(pwd)

Answer — fixed version:

1
2
3
echo $(pwd)
echo $(cd ~ && pwd)
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
2
3
4
5
6
7
8
echo $(pwd)
# /home/user/projects ← wherever you started

echo $(cd ~ && pwd)
# /home/user ← subshell cd'd into home, printed it

echo $(pwd)
# /home/user/projects ← unchanged! the cd only happened inside the subshell

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
2
3
echo $(pwd)
cd ~
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
2
3
4
5
6
find ~/rust-projects -type d -name target -prune -print
# ~/rust-projects/app1/target
# ~/rust-projects/app2/target
# ~/rust-projects/nested/app3/target

find ~/rust-projects -type d -name target -prune -exec rm -rf {} +

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
2
rm -rf /path/to/base/app1/target
rm -rf /path/to/base/app2/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
2
cd /path/to/base/app1
cargo clean

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:

  1. Parses Cargo.toml / Cargo.lock to resolve the workspace and target directory location (which might not just be ./target — could be overridden via CARGO_TARGET_DIR , workspace config, etc.).
  2. Determines exactly what belongs to the build output.
  3. 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_DIR is set to something other than ./target , or it’s a workspace with a shared target dir, blind rm -rf target might miss the real location.
  • Selectivity: cargo clean -p somecrate or cargo clean --release lets 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
2
3
4
5
6
$ find /d/study/rust -type f -name Cargo.toml -execdir cargo clean \;
Removed 131 files, 9.9MiB total
find: ‘/d/study/rust/advance/channel/target’: No such file or directory
Removed 72 files, 6.4MiB total
find: ‘/d/study/rust/advance/closure/target’: No such file or directory
Removed 79 files, 4.3MiB total

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 find being surprised that a directory it queued for traversal no longer exists.
  • Order of operations: readdir (lists target/ ) → -execdir runs cargo clean (deletes target/ ) → find later tries to descend into the now-deleted target/ → 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
2
3
4
file="report.tar.gz"
echo "${file%.gz}" # report.tar — strip shortest matching suffix
echo "${file%%.*}" # report — strip LONGEST matching suffix
echo "${file#*.}" # tar.gz — strip shortest matching prefix

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
2
3
4
5
cat <<- EOF
Usage: $0 [options] <file>
Processes <file> and reports totals.
Run on $(date).
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
2
3
Usage: myscript [options] <file>
Processes <file> and reports totals.
Run on Fri Jul 24 07:15:32 UTC 2026.

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
2
3
4
5
cat <<-EOF
Usage: $0 [options] <file>
Processes <file> and reports totals.
Run on $(date).
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
2
3
4
5
cat <<EOF
Usage: $0 [options] <file>
Processes <file> and reports totals.
Run on $(date).
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
2
3
4
n=5
for i in $(seq 1 "$n"); do
echo "$i"
done

A: Yes, that’s valid bash. It will print:

1
2
3
4
5
1
2
3
4
5

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
2
3
for i in $(seq 1 "$n"); do  # seq when the bound is a VARIABLE ({1..$n} does NOT expand)

done

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
2
n=5
for i in {1..$n}; do echo "$i"; done

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
2
3
while read -r line; do
echo "got: $line"
done

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 want while read -r ; words want for . 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
2
3
while read -r line; do
echo "got: $line"
done

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.

  • -r just means “don’t treat backslashes specially” (e.g. don’t mangle \n or \t in the text). You should basically always include it.

The bad pattern people try instead:

1
2
3
for line in $(cat file); do
echo "got: $line"
done

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
2
3
hello world
foo

With while read -r line , you get:

1
2
3
got: hello world
got: foo

Two iterations, one per line. Correct.

With for line in $(cat file) , you get:

1
2
3
4
got: hello
got: world
got: foo

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
2
path\to\nfile

Without -r :

1
2
3
4
while read line; do
echo "got: $line"
done <<< 'path\to\nfile'

Output:

1
2
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
2
3
4
while read -r line; do
echo "got: $line"
done <<< 'path\to\nfile'

Output:

1
2
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 of IFS (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
2
3
4
5
for x in ${arr[@]}; do echo "[$x]"; done
# [hello] [world] [foo] [bar] [baz]

for x in ${arr[*]}; do echo "[$x]"; done
# [hello] [world] [foo] [bar] [baz]

Quoted — this is where it matters:

1
2
3
4
for x in "${arr[@]}"; do echo "[$x]"; done
# [hello world]
# [foo]
# [bar baz]

Each element stays intact as its own word — this is what you almost always want.

1
2
for x in "${arr[*]}"; do echo "[$x]"; done
# [hello world foo bar baz]

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
2
"$@"   # each argument preserved separately (safe for loops, passing args)
"$*" # all arguments joined into one string

# 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
2
arr[name]="Alice"
arr[age]=30

All at once:

1
declare -A arr=( [name]="Alice" [age]=30 [city]="NYC" )

# Accessing values

1
2
3
echo "${arr[name]}"     # Alice
echo "${arr[age]}" # 30

# Getting all keys / all values

1
2
3
echo "${!arr[@]}"       # name age city   (the keys)
echo "${arr[@]}" # Alice 30 NYC (the values)

# Looping over it (the common pattern)

1
2
3
4
for key in "${!arr[@]}"; do
echo "$key => ${arr[$key]}"
done

# Other useful bits

1
2
3
4
${#arr[@]}              # number of elements
unset arr[age] # delete a key
[[ -v arr[name] ]] # check if a key exists (bash 4.2+)

# Common gotchas

  • declare -A is requiredarr=([foo]=bar) without it silently creates a broken indexed array (using 0 as the numeric interpretation of foo … 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
2
3
4
if cd /nope then
echo "ok"
fi

Problem: Missing a ; (or newline) between the command and then . Bash needs a statement terminator there.

Fixed:

1
2
3
4
if cd /nope; then
echo "ok"
fi

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
2
3
4
5
6
if cd /nope; then
echo "ok"
else
echo "cd failed"
fi

Or if you just want to attempt the cd and bail out on failure (a common script pattern):

1
2
cd /nope || { echo "cd failed"; exit 1; }


# Q25: Explain nums=($line)

1
2
3
read line
nums=($line) # word-splitting fills the array

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
2
3
4
5
6
7
8
line="10 20 30"
nums=($line)

echo "${nums[0]}" # 10
echo "${nums[1]}" # 20
echo "${nums[2]}" # 30
echo "${#nums[@]}" # 3 (number of elements)

Because $line is unquoted inside the parentheses, bash performs:

  1. Word-splitting — the string is split wherever IFS characters occur (default: space, tab, newline)
  2. 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
2
3
4
line="file1 * file2"
nums=($line)
# if there happen to be files in the current directory, "*" expands to their names!

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
2
read -a nums <<< "$line"

Or if globbing is a concern with the original approach, disable it temporarily:

1
2
3
4
set -f          # disable globbing
nums=($line)
set +f # re-enable 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
2
3
$ echo 'C:\new\test' | (read line; echo "$line")
C:newtest

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
2
3
$ printf 'first\\\nsecond\n' | (read line; echo "[$line]")
[firstsecond]

A trailing \ at the end of a line tells read “this continues onto the next line” — so two lines get silently joined into one.

1
2
3
$ echo 'C:\new\test' | (read -r line; echo "$line")
C:\new\test

Backslashes are preserved exactly as typed. No stripping, no line-continuation magic.

# Real-world example: reading file paths

1
2
3
4
5
6
7
8
9
10
11
12
# Without -r — breaks on Windows-style or backslash-containing paths
while read path; do
echo "Path: $path"
done <<< 'C:\Users\Alice\Documents'
# Output: Path: CUsersAliceDocuments (backslashes eaten!)

# With -r — correct
while read -r path; do
echo "Path: $path"
done <<< 'C:\Users\Alice\Documents'
# Output: Path: C:\Users\Alice\Documents

# Real-world example: reading lines from a file

This is the classic pattern, and -r is considered essential here:

1
2
3
4
while IFS= read -r line; do
echo "Line: $line"
done < myfile.txt

  • IFS= — prevents leading/trailing whitespace from being trimmed
  • read -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
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash
verbose=0
file=""
while getopts "vf:" opt; do
case $opt in
v) verbose=1 ;;
f) file=$OPTARG ;;
*) echo "Usage: $0 [-v] [-f file]" >&2; exit 1 ;;
esac
done
shift $((OPTIND - 1))
echo "verbose=$verbose file=$file remaining=$@"

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
2
./script.sh -v -f config.txt input1.txt input2.txt

Positional args before any shifting:

1
2
3
4
5
6
$1 = -v
$2 = -f
$3 = config.txt
$4 = input1.txt
$5 = input2.txt

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
2
-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
2
3
$1 = input1.txt
$2 = input2.txt

# Testing it

1
2
3
$ ./script.sh -v -f config.txt input1.txt input2.txt
verbose=1 file=config.txt remaining=input1.txt input2.txt

Without the shift line, remaining="$@" would instead print:

1
2
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
2
3
4
5
6
populate() {
local -n out=$1
out=(a b c)
}
populate myarr

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
2
3
populate myarr
echo "${myarr[@]}" # a b c

# 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
2
3
4
5
populate() {
eval "$1=(a b c)"
}
populate myarr

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
2
3
4
5
populate() {
local -n out=$1
out=(a b c)
}

# A more realistic example

1
2
3
4
5
6
7
8
9
10
11
12
fill_with_squares() {
local -n result=$1
local n=$2
result=()
for ((i=1; i<=n; i++)); do
result+=($((i*i)))
done
}

fill_with_squares squares 5
echo "${squares[@]}" # 1 4 9 16 25

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 — out can’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
    3
    populate 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 -n only 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
2
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:

  1. Bash forks a whole separate process just to shuffle bytes from a file into a pipe
  2. Data has to flow: disk → cat’s stdout → pipe buffer → cmd’s stdin, instead of cmd reading the file directly

# The better way

1
2
cmd < file

or equivalently:

1
2
< 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
2
3
4
5
6
7
8
9
# Wasteful — spawns cat unnecessarily
cat access.log | grep "ERROR"

# Better — grep reads the file itself
grep "ERROR" access.log

# Or if you specifically want to use redirection syntax
grep "ERROR" < access.log

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
2
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
2
cat file | some_stdin_only_tool

Here you could still avoid cat via redirection:

1
2
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
2
cat data.csv | cut -d, -f1 | sort | uniq -c

vs.

1
2
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
2
3
4
count=0
while read -r f; do (( count++ )); done < <(ls)
echo "$count" # actual count

# < <(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
2
3
4
count=0
while read -r f; do (( count++ )); done < <(ls)
echo "$count" # correct — e.g. 12

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
2
3
4
count=0
ls | while read -r f; do (( count++ )); done
echo "$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
2
(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
2
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
2
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)

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 *.txt expands 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
2
find . -name *.txt

Bash sees *.txt as an unquoted glob and tries to expand it against files in the current directorybefore 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
2
3
notes.txt
report.txt

1
2
find . -name *.txt

Bash expands *.txtnotes.txt report.txt first, so the command that actually executes is:

1
2
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
2
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
2
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
2
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
2
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
2
3
find . -name '*.txt'
# finds: ./notes.txt, ./report.txt, ./sub/dir/todo.txt, etc.

# Rule of thumb

Any time a wildcard is meant for the command itself to interpret (not for the shell), quote it:

1
2
3
4
find . -name '*.txt'
grep 'error*' file.txt # (though this is regex, not glob — different meaning, still worth quoting)
rsync --include='*.log' ...

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
2
3
4
./long-running &
bg_pid=$!
trap "kill $bg_pid 2>/dev/null" EXIT
wait $bg_pid

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" EXIT registers a cleanup handler that fires on any script exit (normal, error, or interrupt) and kills the background process. 2>/dev/null suppresses the harmless error if the process already exited. Since the trap string is double-quoted, $bg_pid is expanded immediately when the trap is registered, not when it fires.
  • wait $bg_pid blocks 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
2
3
4
5
6
mkfifo /tmp/pipe
# Process A:
echo "hello" > /tmp/pipe
# Process B:
read -r msg < /tmp/pipe
echo "got: $msg"

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
2
3
4
5
6
mkfifo /tmp/pipe
echo "hello" > /tmp/pipe &
read -r msg < /tmp/pipe
echo "got: $msg"
wait
rm /tmp/pipe

Run each side in a separate process/terminal.

Open the FIFO read-write on one fd (avoids blocking entirely):

1
2
3
4
5
6
7
mkfifo /tmp/pipe
exec 3<>/tmp/pipe
echo "hello" >&3
read -r msg <&3
echo "got: $msg"
exec 3>&-
rm /tmp/pipe

# Q34: Coprocess with bc

1
2
3
4
coproc CALC { bc -l; }
echo "3 + 4" >&"${CALC[1]}" # write to coproc's stdin
read result <&"${CALC[0]}" # read coproc's stdout
echo "$result"

Explanation:

  • coproc CALC { bc -l; } starts bc -l in the background with two pipes: CALC[1] (write end → coprocess stdin) and CALC[0] (read end → coprocess stdout). $CALC_PID holds its PID.
  • echo "3 + 4" >&"${CALC[1]}" sends the expression to bc .
  • read result <&"${CALC[0]}" reads the computed result ( 7 ) back.
  • echo "$result" prints 7 .

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
2
3
4
5
6
7
8
9
# BAD — forks for every iteration
for i in $(seq 1 1000); do
count=$(echo "$i" | wc -c)
done
# GOOD — pure bash
for (( i = 1; i <= 1000; i++ )); do
count=${#i}
done
times

What times shows: cumulative CPU time for the current shell and all its completed child processes, in the form:

1
2
<shell user time> <shell system time>
<children user time> <children system time>

Given output:

1
2
0m0.015s 0m0.098s
0m0.430s 0m0.026s
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
echo "=== BAD loop ==="
time {
for i in $(seq 1 1000); do
count=$(echo "$i" | wc -c)
done
}

echo "=== GOOD loop ==="
time {
for (( i = 1; i <= 1000; i++ )); do
count=${#i}
done
}

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
2
3
4
5
6
7
results=()
for host in "${hosts[@]}"; do
{
ping -c1 "$host" > /tmp/p_$$_${host} &
}
done
wait

$$ 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
2
3
if [[ -n "${__LIB_LOG_SOURCED:-}" ]]; then return; fi
__LIB_LOG_SOURCED=1
log_info() { ... }

${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
2
3
4
5
kill -TERM "$pid"
sleep 5
if kill -0 "$pid" 2>/dev/null; then # still alive?
kill -KILL "$pid" # force it
fi

( 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
2
3
4
5
6
7
8
9
10
11
on_error() {
local line=$1
local cmd=$2
echo "FAIL line $line: $cmd" >&2
echo "call stack:" >&2
local i
for ((i = ${#FUNCNAME[@]} - 1; i >= 0; i--)); do
echo " $i: ${FUNCNAME[$i]}() in ${BASH_SOURCE[$i]}:${BASH_LINENO[$i-1]}" >&2
done
}
trap 'on_error $LINENO "$BASH_COMMAND"' ERR

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
2
3
4
5
FUNCNAME    = ( baz  bar  foo  main )
[0] [1] [2] [3]

BASH_LINENO = ( 3 2 1 ... )
[0] [1] [2]
  • BASH_LINENO[0] = line where baz was called (inside bar )
  • BASH_LINENO[1] = line where bar was called (inside foo )
  • BASH_LINENO[2] = line where foo was 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.

Edited on