skip to content

Running `ssh host /opt/bin/start-agent.sh` never returns to your prompt even though the script finished and printed its last line. The script leaves a background process running. How do inherited file descriptors explain the hang, and what redirection fixes it?

level: seniorimportance: nice to knowfreq 28%

answer

  1. EOF needs the last writer to close
  2. the child got a copy of the table
  3. the command finished, the stream did not
  4. give the child its own three
  5. and close the extras with a dash

basics

~20 s

The background process inherited copies of the script's stdout and stderr, which are the pipes back to the ssh client. ssh keeps reading until every writer closes, so it waits on the still-running child. Redirect the child's descriptors away from those pipes when starting it: cmd >/dev/null 2>&1 </dev/null &.

solid answer

~50 s

When `ssh` runs a remote command it wires that command's stdout and stderr to pipes it reads until end-of-file. EOF arrives only when the **last** writer closes its end — and every process forked by the script inherited a duplicate of those descriptors. The script exits, but the background agent is still holding open copies of fd 1 and fd 2, so the pipes never reach EOF and the client sits there with no output to show. The fix is on the descriptors, not the process: start the child with its own destinations, `nohup /opt/bin/agent >/var/log/agent.log 2>&1 </dev/null &`, so it inherits nothing that points back at ssh. Redirecting stdin matters too, because a background reader on the ssh stdin pipe can block the session as well. Note this is a different failure from the child being killed when the connection drops — that is a signal problem; this is purely an open-descriptor problem, and a job left running with the pipes closed exits cleanly.

code

bash · 10 lines
bash
#!/usr/bin/env bash
set -euo pipefail

exec 3>/var/run/deploy.lock          # script's own channel

# Hand the child its own descriptors and drop the inherited extras.
nohup /opt/bin/agent \
  >/var/log/agent.log 2>&1 </dev/null 3>&- &

echo "agent pid $!"                  # ssh returns as soon as this exits

go deeper

for a junior

Know the safe way to launch something that outlives the script: cmd >/var/log/x.log 2>&1 </dev/null &, giving it its own three standard descriptors rather than the ones it inherited.

for a middle

Explain that a forked child gets a duplicate descriptor table, and that a pipe signals end-of-file only when the last writer closes — so a surviving child keeps the ssh channel open even after the parent exits.

for a senior

Diagnose it rather than guess: inspect /proc/<pid>/fd or lsof for a stray pipe: entry, separate this from the SIGHUP failure, and remember descriptors above 2 — the same refcount explains a deleted file that will not free its space.

for a principal

Draw the boundary: a process that must outlive the invoking script is a service, and its descriptors, logging and restart policy belong to a supervisor rather than to an ssh-launched shell script. Make that the standard so the class of bug disappears.

## What ssh is actually waiting for `ssh host command` does not wait for a process — it waits for a stream. The remote `sshd` creates pipes for the command's stdout and stderr and forwards whatever comes out of them to your client. The client stops when those channels close, which happens when the pipe returns end-of-file, which happens only when **every** descriptor referring to the write end has been closed. That is the crux: EOF on a pipe is refcounted, not owned. The remote command exiting closes *its* copies. Any other process still holding a copy keeps the pipe open. ## How the child got a copy When a shell forks a child, the child starts with a duplicate of the parent's entire descriptor table. Descriptor 1 in the child points at the same open pipe as descriptor 1 in the parent. So: ```bash #!/usr/bin/env bash /opt/bin/agent & # inherits fd 0, 1, 2 from the script echo "agent started" # this line reaches you fine ``` The script exits. `sshd` reaps it and knows the exit status. But `agent` is alive, holding write ends of both pipes, so the channels stay open and the client keeps waiting for output that will never come. You see the last line of the script, then nothing, until you press `~.` or the agent dies. The command *succeeded*; the session is what is stuck. ## The fix is a redirection, not a signal Give the child its own descriptors so nothing it holds points back at ssh: ```bash nohup /opt/bin/agent >/var/log/agent.log 2>&1 </dev/null & ``` Each part earns its place: - `>/var/log/agent.log` replaces fd 1 with a file, so the child no longer holds the stdout pipe. - `2>&1` duplicates that file onto fd 2, releasing the stderr pipe as well. (Order matters — after the stdout redirection, as always.) - `</dev/null` replaces fd 0. Without it the child keeps the ssh stdin pipe open, and if it ever reads it can steal input or block. If the agent should be silent rather than logged, `>/dev/null 2>&1` does the same job. ## Descriptors above 2 count too The rule is about *every* descriptor, not just the standard three. A script that opened its own channel earlier hands that to children as well: ```bash exec 3>/var/run/deploy.lock ... /opt/bin/agent 3>&- >/var/log/agent.log 2>&1 </dev/null & ``` `3>&-` closes descriptor 3 for that command only, so the long-lived agent does not sit on a lock file or an open log the script meant to release. Forgetting this is how a deleted log file keeps consuming disk space (`df` shows it used, `du` cannot find it) and how a lock outlives the run that took it — the space and the lock are freed only when the last descriptor closes. ## Related but distinct problems Two failures live near this one and are worth separating cleanly in an interview, because conflating them is the usual mistake: - **Child dies when the session ends.** That is signal delivery on the controlling terminal, and `nohup` or `setsid` addresses it. Nothing to do with pipes. - **Session hangs though the command finished.** That is this problem: open descriptors keeping a stream from reaching EOF. `nohup` alone does *not* fix it, because `nohup` only redirects stdout (and only when it is a terminal) — under ssh the descriptor is a pipe, not a terminal, so `nohup` leaves it attached and the hang remains. Write the redirections explicitly. ## Confirming it rather than guessing On a Linux host, `ls -l /proc/<agent-pid>/fd` shows what the stray process is holding; a `pipe:[12345]` entry on fd 1 is the smoking gun, and `lsof -p <pid>` gives the same answer with names. Once you can see the pipe in the child's table, the diagnosis stops being a theory. ## The durable rule Anything you leave running after the script exits must be handed its own stdin, stdout, stderr and any extra descriptors — closed or pointed somewhere durable. A background process is not detached until its descriptor table says so. In practice this is why a real long-lived service belongs under a supervisor that sets up its descriptors deliberately, rather than being forked from a script over ssh at all.

  • Why does adding `nohup` alone not fix the ssh hang?
    `nohup` addresses a different failure: it makes the child ignore SIGHUP so it survives the session ending. It redirects stdout only when stdout is a terminal — under `ssh host cmd` it is a pipe, so `nohup` leaves it attached, the child keeps the write end open, and the client still waits for EOF. You need the explicit `>file 2>&1 </dev/null` redirections.
  • How would you confirm the diagnosis on the remote host rather than assuming it?
    Find the stray process and inspect its descriptor table: `ls -l /proc/<pid>/fd` on Linux, or `lsof -p <pid>`. If fd 1 or 2 shows a `pipe:[...]` entry rather than a file or /dev/null, that process is the one holding the ssh channel open. The same check finds a child sitting on a deleted log file.
  • A deleted multi-gigabyte log file is not freeing disk space and a script-launched child is still running. How is that the same mechanism?
    It is the same refcount. Unlinking a file removes the directory entry, but the data is released only when the last open descriptor referring to it closes — and the child inherited a copy. Closing that descriptor, or restarting the child with `>/dev/null` and `3>&-` for extras, releases the space.
  • What is the difference between `child 3>&-` and `exec 3>&-` before starting the child?
    `child 3>&-` closes descriptor 3 for that one command, leaving the script's own copy open so it can keep using the channel. `exec 3>&-` closes it in the shell itself, so it is gone for the script and every later child. Use the per-command form when the script still needs the descriptor after forking.

saying these in an interview costs you the question

  • Blaming SIGHUP for a hang after the command finished
  • Believing nohup detaches the child's descriptors
  • Thinking ssh returns as soon as the command exits
  • Redirecting only stdout and leaving stderr on the pipe
  • Forgetting the child inherits descriptors above 2

context