Sessions, Process Groups, Controlling Terminals & Job Control

This is the chapter that separates people who can debug terminals from people who cannot. It covers five interlocking concepts — sessions, session leaders, process groups, controlling terminals, and foreground process groups — plus TIOCSCTTY and the job-control signals.

Read it slowly. Then do Experiment 5 in Lab 4 with ps open in a second window, because this is a chapter you learn by watching numbers change.


The Three Nested Containers

 SESSION  sid=4242
 │  • created by setsid()
 │  • has exactly one SESSION LEADER (the process whose pid == sid)
 │  • has AT MOST ONE controlling terminal
 │  • the terminal has exactly ONE foreground process group
 │
 ├─ PROCESS GROUP  pgid=4242      ← the shell's own group
 │   └─ bash (pid 4242)  ← session leader AND group leader
 │
 ├─ PROCESS GROUP  pgid=4310      ← job 1:  "sleep 100 &"
 │   └─ sleep (pid 4310)
 │
 └─ PROCESS GROUP  pgid=4315      ← job 2:  "cat file | grep x | wc -l"
     ├─ cat  (pid 4315)  ← group leader
     ├─ grep (pid 4316)
     └─ wc   (pid 4317)

 The terminal /dev/pts/7 has:  foreground pgid = 4315   (job 2 is running in the foreground)
 Everything else is a background process group.

Three integers per process, visible in ps:

FieldMeaningps column
pidThis processPID
pgidIts process group (a job)PGID
sidIts session (a login, or a terminal window)SID / SESS
—The terminal's foreground pgidTPGID

TPGID is a property of the terminal, not of the process, but ps shows it per process because it reads it from the process's controlling terminal. When TPGID == PGID, that process is in the foreground.


Concept 1: Process Group

1. What problem it solves

A pipeline is one job made of several processes. When you press Ctrl+C you want to interrupt all of them, not just the last one. A process group is a set of processes that can be signalled as a unit.

2. Where it exists

Kernel, as an integer on each process plus a group structure.

3. Who owns/interacts

The shell creates and manages process groups. It puts every process of a pipeline into one group whose pgid equals the first process's pid.

4. Syscalls and signals

getpgrp()              → my process group id
setpgid(pid, pgid)     → move a process into a group; pgid==0 means "use pid"
                         restrictions: only for yourself or a child that has not yet exec'd
killpg(pgid, sig)      → signal every process in the group
kill(-pgid, sig)       → the same thing, spelled with a negative pid

Note: The shell calls setpgid() in both the parent and the child after fork(). That is not redundancy — it is a race fix. Whichever runs first wins, and the other becomes a harmless no-op. Without it, the shell might call tcsetpgrp() on a group that does not exist yet, or the child might exec before being placed in its group.

5. Experiment

sleep 100 | cat &
ps -o pid,ppid,pgid,sid,tpgid,stat,comm
# Both `sleep` and `cat` share one PGID.  Kill the job as a unit:
kill -TERM -$(ps -o pgid= -p $! | tr -d ' ')

6. Failure mode

If the shell does not put a pipeline in one group, Ctrl+C kills only part of it. You end up with an orphaned grep holding a pipe open, and the pipeline never terminates.


Concept 2: Session and Session Leader

1. What problem it solves

A session is "everything belonging to one login / one terminal window." It is the unit that gets hung up when the terminal disappears, and it is the scope in which a controlling terminal exists.

2. Where it exists

Kernel.

3. Who owns/interacts

The session leader is the process whose pid == sid. It is created by calling setsid(). For a login shell, the leader is the shell. For your PTY runner, the leader is the child you spawn.

4. Syscalls

setsid()      → create a new session:
                  • new session, sid = pid
                  • new process group, pgid = pid
                  • DROPS any controlling terminal the caller had
                  • FAILS with EPERM if the caller is already a process group leader
getsid(pid)   → query

Why does setsid() fail for a group leader? Because a process group must live entirely within one session. If a group leader could move to a new session, its group members would be split across two sessions. The kernel refuses rather than allow that. This is why you call setsid() in a freshly-forked child: a fresh child is never a group leader (its pgid is inherited from the parent, whose pid it does not share).

Warning: The classic manifestation is a daemonizing loop that calls setsid() in a process that happened to be started as a group leader — from a shell's job control, say. It returns EPERM, the code ignores the error, and everything "works" until a signal goes to the wrong place. Always check the return value.

5. Experiment

# Your shell is (usually) a session leader:
ps -o pid,pgid,sid,comm -p $$
# pid == pgid == sid for a login shell

# setsid(1) creates a new session — watch the sid change:
setsid --wait ps -o pid,pgid,sid,tty,comm     # Linux
# Note the TTY column: "?" — the new session has NO controlling terminal.

Predict first: what does tty print inside setsid tty? Why?

6. Failure mode

A "daemon" that never called setsid() remains in its parent's session, keeps the controlling terminal, and dies with SIGHUP when the terminal closes. This is exactly the bug your mux server must not have (Milestone 11).


Concept 3: Controlling Terminal

1. What problem it solves

Terminal-generated events — ^C, ^Z, resize, hangup — need a well-defined set of recipients. The controlling terminal is the link: this session is attached to this terminal, so this terminal's events go to this session's foreground process group.

2. Where it exists

Kernel, as a link in both directions: session → terminal, and terminal → session + foreground pgid.

 ┌──────────────────────┐        ┌─────────────────────────────┐
 │ SESSION  sid=4242    │◀──────▶│ TTY /dev/pts/7              │
 │  ctty = /dev/pts/7   │        │  session   = 4242           │
 └──────────────────────┘        │  fg pgroup = 4315  (TPGID)  │
                                 │  termios   = { ... }        │
                                 │  winsize   = { 24, 80 }     │
                                 └─────────────────────────────┘

3. Who owns/interacts

A session owns at most one. A process acquires one for its session by being the session leader, having none, and then either opening a tty without O_NOCTTY (on systems where that grabs it) or — explicitly and portably — calling ioctl(fd, TIOCSCTTY, 0).

4. Syscalls

ioctl(fd, TIOCSCTTY, 0)   → make fd's terminal the controlling terminal of my session
                            REQUIRES: I am a session leader with no controlling terminal
                            (arg 1 with CAP_SYS_ADMIN can steal one from another session — don't)
open("/dev/tty")          → a magic path meaning "my controlling terminal", whatever it is
                            fails with ENXIO if the session has none
ioctl(fd, TIOCNOTTY)      → give up the controlling terminal
tcgetsid(fd)              → which session owns this terminal

/dev/tty is worth remembering: it is how a program reaches the terminal even when 0/1/2 have been redirected. That is how ssh reads a password when stdin is a pipe, and how less still gets keystrokes in cmd | less.

The mandatory ordering:

   setsid()                     ✓  now I am a session leader with no ctty
       │
       ▼
   ioctl(slave, TIOCSCTTY, 0)   ✓  succeeds

   ─────────────────────────────────────────────────

   ioctl(slave, TIOCSCTTY, 0)   ✗  EPERM — I am not a session leader
       │                            (or: I already have a controlling terminal)
       ▼
   setsid()                     — too late; and it would drop the ctty anyway

5. Experiment

# Which session owns your terminal?
ps -o pid,pgid,sid,tty,comm -p $$
# Reach the controlling terminal even with redirected stdio:
echo "direct to ctty" > /dev/tty
sh -c 'echo hi > /dev/tty' < /dev/null > /dev/null 2>&1   # still appears on screen

# A session with no controlling terminal cannot open /dev/tty:
setsid sh -c 'echo test > /dev/tty' ; echo "exit=$?"      # Linux: fails, ENXIO

6. Failure mode

This is the single most common PTY bug, and its symptoms are misleading:

SymptomCause
bash: cannot set terminal process group (-1): Inappropriate ioctl for deviceThe child has no controlling terminal; tcsetpgrp fails
bash: no job control in this shellSame
Ctrl+C does nothingNo controlling terminal ⇒ no foreground process group ⇒ nowhere to send SIGINT
vim starts but behaves strangelyIt cannot manage the terminal it does not control
top runs but does not respond to qSame root cause

All four come from the same three-line mistake: setsid() missing, TIOCSCTTY missing, or the two in the wrong order.


Concept 4: Foreground Process Group

1. What problem it solves

At any moment, exactly one job should receive keystrokes and terminal signals. Everything else should be in the background, and should be stopped if it tries to read.

2. Where it exists

Kernel, as a field on the terminal.

3. Who owns/interacts

The shell sets it. The kernel enforces it. ps shows it as TPGID.

4. Syscalls and signals

tcgetpgrp(fd)          → the terminal's current foreground pgid
tcsetpgrp(fd, pgid)    → set it.  Caller must be in the same session as the terminal.
                         If called by a BACKGROUND process, it gets SIGTTOU (unless blocked/ignored)

Terminal-generated signals, and who receives them:

Inputtermios control charSignalSent to
Ctrl+C (0x03)VINTRSIGINTForeground process group
Ctrl+\ (0x1c)VQUITSIGQUIT (+ core dump)Foreground process group
Ctrl+Z (0x1a)VSUSPSIGTSTPForeground process group
— (window resize)—SIGWINCHForeground process group
— (master closed)—SIGHUPSession leader (which then HUPs its jobs)
Background process read()s—SIGTTINThe reading process's group
Background process write()s, with TOSTOP set—SIGTTOUThe writing process's group

Note: All of these require ISIG to be set in c_lflag for the character-driven ones. In raw mode ISIG is cleared and 0x03 is just data. SIGWINCH and SIGHUP are not character-driven and are unaffected by ISIG.

TOSTOP is off by default. That is why a background job can write over your prompt. Turn it on with stty tostop and watch background output stop the job instead.

5. Experiment — the one that makes it click

Open two terminals. In terminal B, run watch -n0.5 'ps -o pid,pgid,sid,tpgid,stat,comm -t pts/N' with A's tty. In terminal A:

echo "1. baseline"        ; ps -o pid,pgid,sid,tpgid,stat,comm
sleep 100                 # foreground: watch TPGID become sleep's PGID
# ^Z
jobs                      # "[1]+  Stopped   sleep 100"     STAT is T
bg                        # now running in background:  TPGID goes back to the shell
fg                        # TPGID becomes sleep's PGID again
# ^C

Then the SIGTTIN demonstration:

cat &                     # a background process that immediately reads from the terminal
jobs                      # "[1]+  Stopped (tty input)"   ← the kernel stopped it with SIGTTIN
fg                        # bring it forward; now it can read
# ^D or ^C to finish

Predict first: before running cat &, predict whether it will (a) read your next keystroke, (b) exit immediately, or (c) stop. Write down which, and why.

6. Failure mode

MistakeSymptom
Shell never calls tcsetpgrpCtrl+C kills the shell instead of the job
tcsetpgrp called from a background process without blocking SIGTTOUThe shell suspends itself — a classic self-deadlock in hand-written shells
Your emulator sets TPGID itselfYou are not the shell; do not do this. Your job is to move bytes; the shell manages job control.

Concept 5: Orphaned Process Groups and SIGHUP

The rule

A process group is orphaned when no member has a parent in the same session but a different process group. Informally: nobody outside the group, but inside the session, is left to restart it.

The kernel refuses to stop an orphaned process group, because a stopped orphan would never be continued — there is no shell left to fg it. Concretely:

  • SIGTSTP/SIGTTIN/SIGTTOU sent to an orphaned group are discarded.
  • If a process group becomes orphaned while it has stopped members, the kernel sends the group SIGHUP followed by SIGCONT.

SIGHUP: the hangup that closes the loop

   emulator closes the master fd
        │
   ═════│═════ KERNEL ══════════════════════════════════════════════
        ▼
   The PTY's "carrier" drops.
   The kernel sends SIGHUP to the SESSION LEADER of the session whose
   controlling terminal this is  (i.e. the shell).
        │
   ═════│═════ USER SPACE ═════════════════════════════════════════
        ▼
   bash receives SIGHUP → sends SIGHUP to each of its jobs → exits
        │
        ▼
   Their process groups become orphaned → any stopped ones get SIGHUP+SIGCONT

This is why:

  • Closing a terminal window kills everything you started in it.
  • nohup cmd exists (it sets SIGHUP to SIG_IGN before exec).
  • disown exists (bash removes the job from its table so it does not HUP it).
  • tmux exists. The tmux server holds the master. Closing your terminal closes the client's terminal, not the panes' PTYs — so no SIGHUP reaches the shells.

Tip: When someone asks you "why does tmux keep my processes alive?", the correct answer is one sentence: because the PTY master is owned by a process that did not die. Everything else is detail.

Experiment

# Terminal A:
tty                        # note it
sleep 300 &
ps -o pid,ppid,pgid,sid,tty,comm -p $!

# Close terminal A entirely (not `exit` — close the window).
# Terminal B:
ps -o pid,ppid,pgid,sid,tty,comm -p <that pid>     # gone

# Repeat with nohup:
nohup sleep 300 &          # then close the window
ps -o pid,ppid,sid,tty,comm -p <pid>   # survives; ppid is now 1 (or a subreaper); tty is "?"

Predict first: after the survivor's parent dies, what is its new PPID, and what is its TTY?


The Full Job-Control Dance, Annotated

What bash does for vim file.txt, in order. Your emulator does none of this — but you must be able to read it, because when it goes wrong the symptom lands in your terminal.

 bash:  fork()
 child: setpgid(0, 0)                    ← new process group for the job
 bash:  setpgid(child, child)            ← same call, race-proofing
 bash:  tcsetpgrp(tty_fd, child_pgid)    ← hand the terminal to the job
        (bash blocks SIGTTOU around this, because it is about to become background)
 child: reset SIGINT/SIGQUIT/SIGTSTP/SIGTTIN/SIGTTOU to SIG_DFL
        (bash ignores some of them; SIG_IGN survives execve — must be undone)
 child: execve("/usr/bin/vim", ...)
 bash:  waitpid(child, &status, WUNTRACED)   ← blocks; WUNTRACED so ^Z is reported

 ... you press ^Z ...
 kernel: SIGTSTP → foreground pgroup → vim stops
 bash:  waitpid returns with WIFSTOPPED
 bash:  tcsetpgrp(tty_fd, bash_pgid)     ← take the terminal back
 bash:  prints "[1]+  Stopped  vim file.txt"

 ... you type `fg` ...
 bash:  tcsetpgrp(tty_fd, child_pgid)
 bash:  killpg(child_pgid, SIGCONT)
 bash:  waitpid(...)

Read that until it is boring. Then read Experiment 5 in Lab 4 where you watch it happen live.


Common Mistakes Table

MistakeConsequenceFix
TIOCSCTTY before setsidEPERM; no job controlReorder
setsid() in a process that is a group leaderEPERM; silently ignoredCall it in a freshly forked child, and check the return
Ignoring the setsid() return valueEverything "works" until a signal misroutesif setsid() < 0 { return Err }
Not resetting SIG_IGN dispositions before execThe shell inherits ignored SIGINT; Ctrl+C deadReset to SIG_DFL in the child
Emulator calls tcsetpgrpFights with the shellDon't. You are the wire, not the shell.
Assuming SIGHUP goes to every processIt goes to the session leader only; the shell propagatesKnow the two hops
Daemon that forgets setsid()Dies when the launching terminal closessetsid() + check

Validation / Self-check

  1. Draw the three nested containers with a shell, a foreground job, and two background jobs.
  2. Why does setsid() fail for a process group leader? Give the invariant it protects.
  3. State the exact preconditions for ioctl(fd, TIOCSCTTY, 0) to succeed.
  4. Which process sets the foreground process group, and which syscall does it use?
  5. Ctrl+C during sleep 100: name the byte, the termios flag that must be set, the signal, and the exact set of processes that receive it.
  6. What is TPGID in ps output, and what does it mean when TPGID == PGID?
  7. Why does cat & stop immediately? Name the signal and the rule.
  8. Why is TOSTOP off by default, and what changes when you turn it on?
  9. Define an orphaned process group and state the two special kernel behaviors for one.
  10. Explain in one sentence why tmux keeps your shells alive when you close the window.
  11. /dev/tty — what is it, and name two programs that depend on it.
  12. Your PTY runner shows bash: no job control in this shell. List the three possible causes in the order you would check them.

Next: termios & the Line Discipline.