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:
| Field | Meaning | ps column |
|---|---|---|
pid | This process | PID |
pgid | Its process group (a job) | PGID |
sid | Its session (a login, or a terminal window) | SID / SESS |
| — | The terminal's foreground pgid | TPGID |
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 afterfork(). 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 calltcsetpgrp()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 returnsEPERM, 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:
| Symptom | Cause |
|---|---|
bash: cannot set terminal process group (-1): Inappropriate ioctl for device | The child has no controlling terminal; tcsetpgrp fails |
bash: no job control in this shell | Same |
| Ctrl+C does nothing | No controlling terminal ⇒ no foreground process group ⇒ nowhere to send SIGINT |
vim starts but behaves strangely | It cannot manage the terminal it does not control |
top runs but does not respond to q | Same 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:
| Input | termios control char | Signal | Sent to |
|---|---|---|---|
Ctrl+C (0x03) | VINTR | SIGINT | Foreground process group |
Ctrl+\ (0x1c) | VQUIT | SIGQUIT (+ core dump) | Foreground process group |
Ctrl+Z (0x1a) | VSUSP | SIGTSTP | Foreground process group |
| — (window resize) | — | SIGWINCH | Foreground process group |
| — (master closed) | — | SIGHUP | Session leader (which then HUPs its jobs) |
Background process read()s | — | SIGTTIN | The reading process's group |
Background process write()s, with TOSTOP set | — | SIGTTOU | The writing process's group |
Note: All of these require
ISIGto be set inc_lflagfor the character-driven ones. In raw modeISIGis cleared and0x03is just data.SIGWINCHandSIGHUPare not character-driven and are unaffected byISIG.
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
| Mistake | Symptom |
|---|---|
Shell never calls tcsetpgrp | Ctrl+C kills the shell instead of the job |
tcsetpgrp called from a background process without blocking SIGTTOU | The shell suspends itself — a classic self-deadlock in hand-written shells |
Your emulator sets TPGID itself | You 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/SIGTTOUsent to an orphaned group are discarded.- If a process group becomes orphaned while it has stopped members, the kernel sends the group
SIGHUPfollowed bySIGCONT.
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 cmdexists (it setsSIGHUPtoSIG_IGNbefore exec).disownexists (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
SIGHUPreaches 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
| Mistake | Consequence | Fix |
|---|---|---|
TIOCSCTTY before setsid | EPERM; no job control | Reorder |
setsid() in a process that is a group leader | EPERM; silently ignored | Call it in a freshly forked child, and check the return |
Ignoring the setsid() return value | Everything "works" until a signal misroutes | if setsid() < 0 { return Err } |
Not resetting SIG_IGN dispositions before exec | The shell inherits ignored SIGINT; Ctrl+C dead | Reset to SIG_DFL in the child |
Emulator calls tcsetpgrp | Fights with the shell | Don't. You are the wire, not the shell. |
Assuming SIGHUP goes to every process | It goes to the session leader only; the shell propagates | Know the two hops |
Daemon that forgets setsid() | Dies when the launching terminal closes | setsid() + check |
Validation / Self-check
- Draw the three nested containers with a shell, a foreground job, and two background jobs.
- Why does
setsid()fail for a process group leader? Give the invariant it protects. - State the exact preconditions for
ioctl(fd, TIOCSCTTY, 0)to succeed. - Which process sets the foreground process group, and which syscall does it use?
- Ctrl+C during
sleep 100: name the byte, thetermiosflag that must be set, the signal, and the exact set of processes that receive it. - What is
TPGIDinpsoutput, and what does it mean whenTPGID == PGID? - Why does
cat &stop immediately? Name the signal and the rule. - Why is
TOSTOPoff by default, and what changes when you turn it on? - Define an orphaned process group and state the two special kernel behaviors for one.
- Explain in one sentence why tmux keeps your shells alive when you close the window.
/dev/tty— what is it, and name two programs that depend on it.- Your PTY runner shows
bash: no job control in this shell. List the three possible causes in the order you would check them.