Skip to content
VLSI Mentor

Linux & Bash · Part II · Module 33 · Debugging Failed Jobs and Broken Flows

A Command That Fails Only Inside the Script

The command works when you paste it and fails when the script runs it, which feels like magic and is always one of four things. This chapter measures each of them on this machine — starting with the one nobody checks, that the shell you tested in is not the shell that ran it, and that the same line built two arguments at the prompt and four in the script.

Advanced26 min readLinuxPart IIDebuggingShellQuoting

Part II · Module 33 · Chapter 33.3 · A Command That Fails Only Inside the Script

Chapter 33.2 replayed a recorded argv and got the same result. This chapter is the inverse situation: the command is right, you have tested it, and it behaves differently when a script runs it.

There are four differences between your prompt and a script's execution, and the first one is the one nobody checks.

1. It Is Not the Same Shell

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ echo $SHELL
/bin/zsh
$ dscl . -read ~/ UserShell | awk '{print $2}'
/bin/zsh
$ printf '#!/bin/bash\necho $BASH_VERSION\n' > /tmp/w.sh; chmod +x /tmp/w.sh; /tmp/w.sh
3.2.57(1)-release

The prompt is zsh. The script is bash 3.2.57. Those are different languages that share a syntax, and a line tested in one is not a line tested in the other. This is the default on every current macOS, and it is extremely common on Linux too, where the prompt is often bash and #!/bin/sh scripts run dash.

Here is a line from a flow wrapper. The image lives under a directory whose name contains a space, which is the kind of thing a vendor drop or a shared area inflicts on you:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
IMAGE="$ROOT/sim builds/sim.vvp"
OPTS="+TESTNAME=smoke +SEED=1"
vvp $IMAGE $OPTS

Pasted at the prompt:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
TB  test=smoke +SEED=1 seed=1 cycles=40 verbosity=LOW
TB  ERROR unknown test 'smoke +SEED=1'
TB  TEST FAILED  test=smoke +SEED=1 checks=0 errors=1
tb/tb_arb.v:165: $finish called at 25 (1s)
  rc=0

Run by the script:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
/…/m33/sim: Unable to open input file.
  rc=255

Two shells, one line, two completely different failures — and the reason is visible the moment you ask each shell how many arguments it built:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  zsh:
    argc=2
      [1] /…/m33/sim builds/sim.vvp
      [2] +TESTNAME=smoke +SEED=1
  bash:
    argc=4
      [1] /…/m33/sim
      [2] builds/sim.vvp
      [3] +TESTNAME=smoke
      [4] +SEED=1

zsh does not word-split an unquoted parameter expansion. bash does. So zsh kept the path intact — and also kept both plusargs glued into one argument, which the testbench read as a test named smoke +SEED=1. bash split the path at its space and passed the first half as the image.

The other four disagreements worth knowing

Every one of these was run in both shells on this machine:

constructzshbash
a="x y z"; set -- $a; echo $#13
a=(p q r); echo ${a[1]}pq
echo /nonexistent/*.xyzerror: no matches foundprints the pattern
false | true; echo ${PIPESTATUS[0]}empty1
$0 inside -czsh/bin/bash

The second row is the one that silently corrupts data: ${a[1]} is the first element in zsh and the second in bash. A loop tested at a zsh prompt that indexes from 1 reads one element earlier in a script, and arrays of filenames do not announce an off-by-one.

The third row is the direction people do not expect: zsh refuses a glob that matches nothing, while bash hands the tool a literal *. So the interactive test is the safe one and the script is where a pattern reaches a tool as a filename — the shape Chapter 3.5 warns about and Chapter 24.5 measured in a filelist search.

The fourth row is why Chapter 32.7's PIPESTATUS fix must be paired with a #!/bin/bash line: under zsh the array is pipestatus, indexed from 1, so ${PIPESTATUS[0]} is empty and exit "" is exit 0.

The practical rule: test the line in the shell that will run it.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ /bin/bash -c 'the exact line here'

Not pasted at your prompt. One extra wrapper, and it removes this entire category.

Four questions in order. Is it the same shell: re-run the line with bin bash dash c. Is it the same options: errexit, nounset, pipefail and xtrace are all off, and expand_aliases too. Is it the same directory: print pwd on both sides. Is it the same environment: diff the two, and use env dash i for a scheduled job.1. Same shell?zsh prompt, bash script2. Same options?all four are off3. Same directory?scripts change it4. Same env?weak unless scheduled/bin/bash -cargc 2 against 4bash -x -cand no aliasespwd on both sidesthen relative pathsenv -i5 PATH entries, no tools12
Figure 1 — the four differences between your prompt and a script's execution, in the order they are worth checking. The first two are reproductions rather than inspections — one command each makes the script's conditions happen at your prompt — which is why they settle the question instead of narrowing it.

2. The Environment Is Not the Same Environment

A flow script that needs a project root:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/bin/bash
echo "  PROJ_ROOT=${PROJ_ROOT:-<unset>}"
[ -n "${PROJ_ROOT:-}" ] || { echo "  needsroot: PROJ_ROOT is not set" >&2; exit 3; }

Three ways to give it one, and only two of them work:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
=== a variable SET but not EXPORTED ===
  PROJ_ROOT=<unset>
  needsroot: PROJ_ROOT is not set
  rc=3
=== the same variable EXPORTED ===
  PROJ_ROOT=/…/m33
  found the image
  rc=0
=== set on the command line of the invocation (one-shot) ===
  PROJ_ROOT=/…/m33
  found the image
  rc=0

A variable you set at the prompt is a shell variable; a child process inherits only environment variables. Chapter 10.1 is the distinction, and the reason it shows up as "works for me" is that the person who tested it had also exported it at some point weeks ago, in a shell that is still running.

How big is the gap, really

It is worth measuring rather than assuming, because the answer is surprising in both directions:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  this shell          : 62 variables
  bash -lc            : 61
  env -i bash -c      : 3
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ comm -23 <(env | cut -d= -f1 | sort) <(/bin/bash -lc 'env' | cut -d= -f1 | sort)
OLDPWD

A login shell loses almost nothing — one variable, and it is OLDPWD. So the folk explanation that "a script gets a different environment" is not true for a script you launch yourself from a terminal.

The gap appears when something else launches it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  PATH entries: interactive 15 / bash -lc 15 / env -i 5
  env -i PATH : /usr/gnu/bin:/usr/local/bin:/bin:/usr/bin:.
  is iverilog reachable? interactive=yes login=yes clean=NO

Three variables and five PATH entries, and the simulator is not on it. That is what a cron job, a CI agent or a batch submission hands your script, and Chapter 32.8 measured the consequence — twenty-four runs that all crashed with exec: vvp: not found, and an empty image digest reported only on standard error.

So the diagnosis splits in two:

If it fails when you run the script, the environment is almost certainly not the difference. Look at Sections 1, 3 and 4 first.

If it fails only under a scheduler or a timer, the environment is the first suspect, and env -i reproduces it exactly, locally, for free.

3. The Working Directory Is Not the Same Directory

Chapter 33.2 measured the shape of this: the working directory does not change what a simulation computes, it changes where everything it writes lands. The verdict was identical from two directories; the waveform moved to the project root; and two runs from the same place overwrote each other's dump.

What makes it a script problem rather than a run problem is that scripts change directory and prompts rarely do. Chapter 31.6 runs every simulation from inside its own run directory precisely so the tool's self-named files are isolated — which means every relative path in the argv is resolved against a directory you were not in when you tested the line.

This is the mechanism that has now appeared in this track more than any other:

chapterwhat broke
26.1a filelist's relative source paths, resolved against the cwd
27.8a tool invocation's relative arguments
30.1every job a launcher started, with logs/long.vvp: Unable to open input file
30.5a relative output path, on a read-only directory
31.6the argv, invalidated by the cd that provides isolation
33.1the image path, had it not been resolved first

One fix, applied once, at the top, before anything moves:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
root=$(cd "$(dirname "$0")/.." && pwd)        # absolute from here on

And the diagnostic, when a script fails on a path that exists: print the working directory next to the path, because the path is almost never the thing that is wrong.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
printf 'about to read %s from %s\n' "$f" "$PWD" >&2

4. The Options Are Not the Same Options

A non-interactive bash starts with none of the safety you may believe is on:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ /bin/bash -c 'set -o | grep -E "^(errexit|nounset|pipefail|xtrace)"'
errexit         off
nounset         off
pipefail        off
xtrace          off

All four off. So a script that does not set them has no error exit, no unset-variable check, no pipeline status and no trace — and Chapter 20.2 covers both what they buy and the significant things they do not catch.

And one option is off in a way that changes what commands mean:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ zsh -ic 'alias rs="vvp /dev/null"; type rs'
rs is an alias for vvp /dev/null
$ /bin/bash -c 'alias rs="vvp /dev/null"; type rs'
rs is /usr/bin/rs
$ /bin/bash -c 'shopt expand_aliases'
expand_aliases  off

expand_aliases is off in a non-interactive shell, so the alias definition was accepted and then ignored. And the name resolved to something else entirely — /usr/bin/rs is a real program, a BSD array-reshaping tool nobody has used on purpose in thirty years.

That is the worst possible outcome of the alias difference: not "command not found", but a different program, found and executed. Chapter 10.5 establishes the lookup order and that aliases are checked first and only interactively; the consequence for debugging is that an alias is not part of a command's meaning inside a script, and a convenience you defined for yourself can be a real binary on somebody else's machine.

5. The Debugging Harness

Putting the four questions in one script, so the comparison is mechanical:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/bin/bash
# whydiff 'COMMAND LINE' -- run one line four ways and show what differs.
set -u
line=${1:-}
[ -n "$line" ] || { echo "whydiff: give me a command line, quoted" >&2; exit 2; }
run() {   # run LABEL SHELL-AND-ARGS...
  local label=$1; shift
  local out rc
  out=$("$@" 2>&1); rc=$?
  printf '  %-26s rc=%-4s %s
' "$label" "$rc" "$(printf '%s' "$out" | head -1 | cut -c1-60)"
}
printf 'whydiff: %s
' "$line"
printf '  cwd=%s
' "$PWD"
run "your prompt's shell"   "${SHELL:-/bin/sh}" -c "$line"
run "bash (what scripts use)" /bin/bash          -c "$line"
run "bash, traced"            /bin/bash -x       -c "$line"
run "bash, clean environment" env -i /bin/bash   -c "$line"

Four lines of output, and whichever one differs from the others names the category:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ export IMAGE="$PWD/sim builds/sim.vvp" OPTS="+TESTNAME=smoke +SEED=1"
$ whydiff 'vvp $IMAGE $OPTS'
whydiff: vvp $IMAGE $OPTS
  cwd=/tmp/m33demo
  your prompt's shell        rc=0    TB  test=smoke +SEED=1 seed=1 cycles=40 verbosity=LOW
  bash (what scripts use)    rc=255  /tmp/m33demo/sim: Unable to open input file.
  bash, traced               rc=255  + vvp /tmp/m33demo/sim builds/sim.vvp +TESTNAME=smoke +SEED=
  bash, clean environment    rc=127  /bin/bash: vvp: command not found

Each row is a hypothesis tested rather than considered, and four different exit statuses from one line is the point: 0, 255, 255, 127.

The same harness on the quoted version, which is how you confirm a fix rather than hope for one:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ whydiff 'vvp "$IMAGE" +TESTNAME=smoke +SEED=1'
whydiff: vvp "$IMAGE" +TESTNAME=smoke +SEED=1
  cwd=/tmp/m33demo
  your prompt's shell        rc=0    TB  test=smoke seed=1 cycles=40 verbosity=LOW
  bash (what scripts use)    rc=0    TB  test=smoke seed=1 cycles=40 verbosity=LOW
  bash, traced               rc=0    + vvp '/tmp/m33demo/sim builds/sim.vvp' +TESTNAME=smoke +SEE
  bash, clean environment    rc=127  /bin/bash: vvp: command not found

Three rows agree and the fourth is the clean environment, which is a separate and known problem.

The traced row is the most informative of the four, and the quoted run shows why: bash's trace prints

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
+ vvp '/tmp/m33demo/sim builds/sim.vvp' +TESTNAME=smoke +SEED=1

— with quotes around the argument that contains a space. The trace is not an echo of your source line; it is the shell telling you the argv it built, quoted so the boundaries are unambiguous. Comparing that against the broken run's unquoted + vvp /tmp/m33demo/sim builds/sim.vvp … is the whole diagnosis in one character. Chapter 33.6 is about reading exactly that.

Practice

  1. Find out what shell your prompt is, from $SHELL and from $PPID, and see whether they agree.
  2. Find out what shell your scripts run by printing $BASH_VERSION from one.
  3. Take a line with an unquoted expansion and run it in both shells, printing the argument count.
  4. Put a space in a path and compare what each shell builds.
  5. Explain which of the two results is more dangerous, and why.
  6. Quote the expansion and confirm both shells agree.
  7. Index an array from 1 in both shells and compare.
  8. Glob something that does not exist in both shells.
  9. Read ${PIPESTATUS[0]} in both shells.
  10. Set a variable without exporting it and run a script that needs it.
  11. Export it and run again; then use the one-shot form.
  12. Count your environment variables at the prompt, in bash -lc, and under env -i.
  13. Diff the variable names between your shell and a login shell.
  14. Check whether your tools are reachable in all three.
  15. Decide, from those numbers, when the environment is a plausible suspect.
  16. Print pwd next to a failing relative path and see which one is wrong.
  17. Resolve your script's root to an absolute path on the first line of work.
  18. Check the four shell options in a non-interactive bash.
  19. Define an alias in a script and then ask type what the name means.
  20. Find out whether that name is a real program on your machine.
  21. Write the four-way harness and run it on a line that misbehaves.
  22. Make the harness compare bash against bash by faking $SHELL, and watch it report no difference.
  23. Replace env -i with an approximation of what your scheduler actually passes.

Check Your Understanding

  1. What shell is the prompt on this machine, and what runs the scripts?
  2. What argument counts did the two shells build from the same line?
  3. Why did zsh keep the path intact?
  4. What did the testbench think the test was called?
  5. Which of the two results is more dangerous, and why?
  6. What is the fix, and why does it make the shells agree?
  7. What is ${a[1]} in each shell?
  8. What does each shell do with a glob that matches nothing, and which direction is surprising?
  9. What is ${PIPESTATUS[0]} under zsh, and what does exit then do?
  10. What is the practical rule for testing a line?
  11. What is the difference between a shell variable and an environment variable?
  12. How many variables did a login shell lose, and which?
  13. What does that tell you about the folk explanation?
  14. How many variables and PATH entries does env -i leave, and is the simulator reachable?
  15. When is the environment a plausible suspect, and when is it not?
  16. Why is the working directory a script problem rather than a run problem?
  17. How many times has the relative-path mechanism appeared in this track?
  18. What is the one-line fix, and what is the diagnostic?
  19. Which four shell options are off in a non-interactive bash?
  20. What happens to an alias defined inside a script?
  21. What did the name rs resolve to, and why is that the worst outcome?
  22. Give the four diagnostic questions in order.
  23. What property do questions 1 and 2 share?
  24. Give the two ways whydiff can lie, and the guard for each.
  25. State the general rule about comparisons.

6. Remember

  • The prompt is zsh and the script is bash 3.2.57. A line tested in one is not a line tested in the other.
  • The same line built 2 arguments in zsh and 4 in bash. zsh does not word-split an unquoted expansion.
  • The prompt's result was the dangerous one, because it ran, produced a verdict and exited 0 under a test name containing a space.
  • Quote the expansion and both shells agree exactly.
  • ${a[1]} is the first element in zsh and the second in bash — an off-by-one that arrays of filenames do not announce.
  • zsh refuses a glob that matches nothing; bash hands the tool a literal *. The interactive test is the safer one.
  • ${PIPESTATUS[0]} is empty under zsh, so a correct status fix goes green under the wrong shell.
  • Test the line with /bin/bash -c, not at your prompt.
  • A child inherits only exported variables — and a login shell loses exactly one, OLDPWD, so the environment is a weak suspect until a scheduler is involved. Then it is three variables, five PATH entries, and no simulator.
  • Scripts change directory and prompts do not. Resolve the root absolutely on the first line; print $PWD beside any failing path.
  • errexit, nounset, pipefail and xtrace are all off, and expand_aliases is off too — so an alias is accepted, ignored, and the name may resolve to a real program instead.
  • Four questions in order: shell, options, directory, environment — and the first two are reproductions rather than inspections.
  • A comparison is only as good as what it compares against. env -i is cleaner than any real scheduler.

7. What Is Next

Three of the four questions are now one command each. Chapter 33.4 takes the fourth apart properly — the failures caused by a variable that is unset, stale, or set to something that was true last month — and builds the check that would have caught each one before any work started.

Where this fits

Part of the Linux & Bash curriculum.