Learn › Bash Redirection and File Descriptors › Descriptor lifetime and shell scope
Descriptor lifetime and shell scope. Try the examples, inspect their output, and explain the result. Practice feedback does not award XP or mastery.
A script writes a log successfully, but its final message never reaches the caller. The log contains that message too. The likely issue is scope: the range of commands affected by a redirection.
One command's redirects normally end when that command finishes. A redirect on a group covers the group. Bash's exec builtin without a program changes the current shell's descriptors for later commands.
This optional lesson uses Bash 5.2 or later in a private practice directory. Every example that changes persistent descriptors runs inside parentheses or a child Bash. This keeps your interactive terminal usable while you investigate lifetime.
In your managed Bash practice terminal
Run camp-redirection 03 to enter this chapter's private directory. While this lab machine is running, the same command returns to it after reconnecting; camp-redirection 03 --fresh creates a new one without deleting earlier files. Run the code blocks there and use cd to leave.
A semicolon ends one command before the next begins. Redirecting the first printf does not redirect the next one.
Predict which printf writes to one.log and which reaches the terminal. Use cat to check the file after both calls.
printf 'logged\n' > one.log
printf 'caller continues\n'
cat one.log
Expected output
caller continues
logged
For a builtin such as printf, Bash can perform the work itself and restore its previous descriptors afterward. Do not assume every command means a new process. The observable rule is the redirect's scope, not an imagined fork for every line.
Braces group commands in the current shell environment. Keep spaces around the braces and a semicolon before the closing brace. Parentheses create a subshell environment: changes there do not update the surrounding shell's variables or descriptors.
Compare both files and the two after-values: the group assignment remains in the caller, while the subshell assignment does not.
label=outside
{
label=group
printf 'group entry\n'
} > group.log
printf 'after group: %s\n' "$label"
(
label=subshell
printf 'subshell entry\n'
) > subshell.log
printf 'after subshell: %s\n' "$label"
cat group.log subshell.log
Expected output
after group: group
after subshell: group
group entry
subshell entry
Both trailing output redirects end with their compound command. The variable difference demonstrates the environment boundary. A subshell is useful when an operation needs temporary shell settings or persistent redirects that should disappear together.
A function names a group of shell commands. Calling it normally runs those commands in the current shell. Attach the redirect to the call when logging should last only for that call.
Check function.log for report detail and the caller line for status 6; the function's redirect ends when the call returns.
report() {
printf 'report detail\n'
return 6
}
report > function.log
status=$?
printf 'caller received status=%s\n' "$status"
cat function.log
Expected output
caller received status=6
report detail
return 6 ends the function with status 6; it does not terminate the surrounding shell. The caller's descriptors are restored after the call, including a failing call. Its status still needs explicit handling. A following successful cat would otherwise become the most recent result.
With no program argument, exec applies redirects to the shell executing it. The following parentheses contain that change.
(
exec > persistent.log
printf 'first logged line\n'
printf 'second logged line\n'
)
printf 'parent still visible\n'
cat persistent.log
Both internal lines enter persistent.log; parent still visible reaches the original output. Inside the subshell, fd 1 changes from inherited output to open A for the log. It stays on A until another redirect changes it or that environment ends.
exec program has a different purpose: it replaces the shell with a program when successful. Commands after a successful replacement do not run. Test the distinction in a disposable child:
bash -c 'exec printf "replacement ran\n"; printf "unreachable\n"'
Only replacement ran appears. A failed replacement has additional shell-option and interactive-mode rules; this example is about successful replacement.
Sometimes a script must change its own output for several operations. Bash can allocate an unused descriptor using {saved}. The variable receives the number, so you need not guess that descriptor 3 is free.
The Bash option varredir_close can change the lifetime of allocated redirects. The example explicitly disables it in a subshell and closes the descriptor itself. This makes ownership clear.
(
shopt -u varredir_close
exec {saved}>&1
exec > session.log
printf 'logged operation\n'
false
status=$?
exec >&"$saved"
exec {saved}>&-
printf 'restored; status=%s\n' "$status"
if bash -c ': >&"$1"' _ "$saved" 2>/dev/null; then
printf 'descriptor leaked\n'
else
printf 'saved descriptor closed\n'
fi
)
The output is restored; status=1, followed by saved descriptor closed. false deliberately produces the failing status. Restore fd 1 before closing the saved descriptor.
Try in the practice terminal: inspect only the logged operation
cat session.log
Expected result
logged operation
| Subshell transition | fd 1 | Allocated saved fd |
|---|---|---|
| Save | Original open A | Same A |
| Redirect | Log open B | A |
| Restore | A | A |
| Close saved fd | A | Closed |
Closing the saved descriptor does not close fd 1's separate reference. A fresh child attempts to duplicate the saved number and confirms it is closed. Its expected diagnostic is suppressed only for this check. Using a child avoids confusing Bash's own temporary descriptors with the one you owned.
Try a simpler repair before reading the answer. A named operation prints starting, then returns 8. Log its output in operation.log, preserve status 8, and print a visible caller message afterward. Avoid persistent exec redirection when a function-call redirect is sufficient.
operation() { printf 'starting\n'; return 8; }
operation > operation.log
status=$?
printf 'operation returned %s; caller is visible\n' "$status"
cat operation.log
The caller message appears first, followed by the saved starting line. Bash restores the call's descriptors automatically. There is no saved descriptor to leak. Choose the smallest scope that expresses the job.
If output still disappears, inspect where the redirect is attached: the call, a surrounding group, or an earlier exec. Opening another terminal does not repair the script's descriptor ownership.
Change the operation to succeed, then fail with another status. Verify that both paths leave the caller's output available. For the explicit save-and-restore version, keep the closed-descriptor check on both paths.
An interrupted process and failed logging open need additional cleanup design in a production script. Do not treat this straight-line example as a complete signal handler. Its purpose is to make descriptor ownership visible before adding more paths.
Next, find out why two descriptors naming the same file can share a read position or have independent positions.
Practice Chapter 3. Descriptor lifetime and shell scope in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.