Learn › Bash Redirection and File Descriptors › The terminal and remote boundaries

Chapter 14. Terminal and remote boundaries

Terminal and remote boundaries. Try the examples, inspect their output, and explain the result. Practice feedback does not award XP or mastery.

The child that ate the next job

A loop reads one hostname per line. The first command succeeds, then the loop stops early. The input file still contains more names. The missing work can come from a child process reading the loop's standard input.

You do not need a remote machine to reproduce this failure. This lesson uses a local child that drains its input, then explains how the same descriptor boundary appears with SSH. No exercise connects to a host or runs a privileged command.

Use Bash in your private practice directory. The supplied checks run scripts with standard input disconnected from a terminal. Your interactive terminal can have different initial descriptor connections; the examples explicitly establish the input they need.

In your managed Bash practice terminal

Run camp-redirection 14 to enter this chapter's private directory. While this lab machine is running, the same command returns to it after reconnecting; camp-redirection 14 --fresh creates a new one without deleting earlier files. Run the code blocks there and use cd to leave.

Demonstrate shared input ownership

The loop's read consumes one line. A child inherits the loop's descriptors unless the shell changes them for that command. Here the child runs cat, which reads the remaining bytes until end of file.

Predict whether the loop will print both handled alpha and handled beta. The child runs after the first read; decide which process gets the remaining line.

printf 'alpha\nbeta\n' > jobs.txt
while IFS= read -r job; do
  printf 'handled %s\n' "$job"
  bash -c 'cat > /dev/null'
done < jobs.txt

Expected output

handled alpha
Process and momentDescriptor 0Next unread input
Loop before its first readjobs.txtalpha, then beta
Loop after its first readSame open inputbeta
Child after startingInherited open inputbeta
Loop after the child exitsSame input, now at EOFNothing

The filename was not shortened. The shared open input advanced. Checking only the file's contents would miss the cause.

Give the child the input it actually needs

Return to Chapters 3 and 4 before reading the repair. Draw one row for the loop's input descriptor, one for the child after inheritance, and one for the shared open-file position after the child's read. With three new job names, predict how many the broken loop handles. Decide which process should receive different stdin, then compare your diagram with the worked change below.

This child does not need job-list input. Redirect only that command from /dev/null, which immediately supplies end of file. The redirect belongs to the child invocation, so the loop can keep its own input.

Confirm both alpha and beta are handled; redirecting only the child from /dev/null leaves the loop's input available.

printf 'alpha\nbeta\n' > jobs-fixed.txt
while IFS= read -r job; do
  printf 'handled %s\n' "$job"
  bash -c 'cat > /dev/null' < /dev/null
done < jobs-fixed.txt

Expected output

handled alpha
handled beta

For a real SSH job that needs no stdin, its -n option serves this purpose. If the remote program needs input, provide that input deliberately instead. Discarding stdin is not a universal fix for every remote command.

A second design gives the loop a dedicated descriptor. Bash read -u 3 reads from descriptor 3. Reserve that number deliberately and close it in children that do not own it.

printf 'cedar\nmaple\n' > dedicated-jobs.txt
while IFS= read -r -u 3 job; do
  printf 'handled %s\n' "$job"
  bash -c 'cat > /dev/null' 3<&- < /dev/null
done 3< dedicated-jobs.txt

The output is handled cedar, then handled maple. The loop owns descriptor 3 for the duration of the redirected loop. The child's 3<&- prevents that child from inheriting the unused job-list handle.

A terminal is a property of a connection

The isatty test asks whether a descriptor refers to a terminal device. Bash exposes this with [[ -t 0 ]] for stdin. It does not ask whether a human launched the process, and it does not prove a terminal is interactive in every useful sense.

Check the message for stdin: the child receives /dev/null, so its result does not depend on whether the parent is attached to a terminal.

bash -c 'if [[ -t 0 ]]; then printf "stdin is a terminal\n"; else printf "stdin is not a terminal\n"; fi' < /dev/null

Expected output

stdin is not a terminal

On Linux, /dev/tty refers to a process's controlling terminal when one exists. Stdin may be a pipe while /dev/tty still reaches that terminal. A scheduled service may have no controlling terminal, so opening /dev/tty can fail. Do not treat it as guaranteed input or silently wait for a person in an automated job.

Keep prompts separate from report bytes

A prompt asks a person for input. Machine-readable stdout should contain only the documented data. Put an ordinary prompt on stderr, or use a dedicated terminal interface with a defined noninteractive fallback.

printf 'y\n' | bash -c '
  printf "Continue? [y/N]\n" >&2
  IFS= read -r reply || exit 1
  if [[ $reply == y ]]; then printf "approved,true\n"; else exit 1; fi
' > decision.csv 2> prompt.log
printf 'data: '; cat decision.csv
printf 'prompt: '; cat prompt.log

The two output lines begin data: approved,true and prompt: Continue? [y/N]. Stderr is a separate channel, not a guarantee of visibility: a caller can redirect or merge it. Define what happens when no response arrives. Production automation should prefer explicit arguments over interactive questions.

Name the shell that opens a remote file

SSH involves at least a local shell, an SSH client, and a remote command interpreter. Redirect placement decides which shell opens a pathname. The following forms are diagrams to read, not commands to run.

Command shapeShell performing the output openFile location
ssh HOST 'generate-report' > local.csvLocal shell, before SSH startsLocal machine
ssh HOST 'generate-report > remote.csv'Remote shell parsing the quoted command textRemote machine
ssh HOST 'generate-report' < input.txtLocal shell opens input.txtLocal input is sent toward the remote command

The outer single quotes preserve the command text for the remote shell. Unquoted or double-quoted local expansions can happen before SSH receives it. Do not interpolate an untrusted filename into remote shell code. Quoting across two interpreters requires reasoning about both boundaries.

An empty local output file can result from a local > open followed by an SSH connection failure. The remote program need not have started. Chapter 12's temporary-file pattern still applies to that local output.

Explain the privileged-open boundary

The same “which process opens it?” question explains why putting sudo before a command does not automatically elevate a redirect written outside that command.

Conceptual operationWho opens the destination?
Ordinary shell parses a redirect attached to a sudo invocationThe ordinary shell, before launching sudo
An authorized privileged writer receives data on stdin and opens its own pathThat writer
An authorized privileged shell parses redirect syntax inside its command textThat privileged shell

This lesson uses no privileged paths. The lesson is process ownership, not a request to add privilege. A destination access error should first lead you to identify the opening process, the intended permissions, and the correct destination.

Ready to practice

Repair the draining-child example with three changed job names. Then explain, before running it, why redirecting the entire loop from /dev/null would process zero jobs. The worked answer is that read itself would receive immediate EOF; the redirect must be scoped to the child.

Return to Chapter 10's writer-lifetime.py after this child-ownership work. Use its saved directory while this lab machine is running, or recreate the supplied probe in a new lab. Before editing, predict whether closing spare will permit EOF if another copy of the writer remains open.

Make a copy named writer-lifetime-changed.py. Add extra = os.dup(w) just after spare = os.dup(w). After os.close(spare), replace the original immediate os.read(r, 1) line with print("ready with extra:", bool(select.select([r], [], [], 0)[0])). Then close extra, assert select.select([r], [], [], 0)[0] is nonempty, and only then restore the original print("after close:", repr(os.read(r, 1))) line. The readiness assertion prevents a mistaken blocking read. Run the copy with timeout --kill-after=1s 3s python3 writer-lifetime-changed.py; a status of 124 means the deadline expired, so inspect the copy before trying again. Compare readiness before and after the final close. Explain which owned reference prevented EOF and record whether you used the earlier worked table. This is a changed writer-lifetime case, not another reread of the original output.

For each remote command shape, label the local and remote opens and identify where a failed connection could leave an empty file. Finally, explain how a program can have piped stdin and a controlling terminal at the same time. You are ready to apply these boundaries to a report exporter with several interacting defects.

Practice Chapter 14. Terminal and remote boundaries in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.