Interactive job control lets a shell manage process groups that share a controlling terminal. The terminal has one foreground process group at a time, while other jobs can run or remain stopped in the background. Foreground ownership, running state, and the shell’s job table are distinct. A background job is not automatically suspended, and a stopped job has not necessarily terminated.

When it breaks

Typing a configured interrupt or suspend character causes the terminal driver to signal the foreground group when terminal signal processing is enabled. It does not simply signal whichever individual process last printed something. Remapped control characters, altered terminal modes, or a program’s signal handlers can change the observed behavior, so key labels alone do not establish a universal result.

A normal background job attempting to read its controlling terminal is stopped by SIGTTIN. Moving it to the background does not hand it an independent keyboard stream. Output is different: background writes are normally allowed, with additional behavior controlled by terminal settings such as TOSTOP. Background execution therefore does not imply complete separation from the terminal.

Job specifications such as %1 belong to a particular shell’s job table; they are not global process identifiers. Another terminal or a noninteractive shell cannot be assumed to manage that same job using the same number. Pipelines and background commands also depend on whether job control is enabled in the actual shell invocation.

Check it quickly

Save the script as jobcontrol.py and run python3 jobcontrol.py on Linux with Bash and Python installed. The recorded run used Linux 6.12.76, Bash 5.2.15, and Python 3.11.2. It creates its own pseudo-terminal and interactive Bash, so it does not send control characters to the terminal from which you launch it:

from pathlib import Path
import os,pty,re,select,signal,termios,time

pid,master=pty.fork()
if pid==0:
    attributes=termios.tcgetattr(0);attributes[3]&=~termios.ECHO;attributes[3]|=termios.ISIG|termios.ICANON
    attributes[6][termios.VINTR]=b'\x03';attributes[6][termios.VSUSP]=b'\x1a';attributes[6][termios.VEOF]=b'\x04'
    termios.tcsetattr(0,termios.TCSANOW,attributes)
    os.execve('/bin/bash',['bash','--noprofile','--norc','-i'],{'PATH':'/usr/bin:/bin','TERM':'dumb','LC_ALL':'C','PS1':'XB-PROMPT> ','HISTFILE':'/dev/null'})
known_groups={pid};pending=b''
def until_prompt():
    global pending
    deadline=time.monotonic()+5
    while b'XB-PROMPT> ' not in pending:
        remaining=deadline-time.monotonic();assert remaining>0,pending
        ready,_,_=select.select([master],[],[],remaining);assert ready,pending
        chunk=os.read(master,65536);assert chunk,'PTY closed before prompt';pending+=chunk
    output,pending=pending.split(b'XB-PROMPT> ',1)
    return output.decode(errors='replace')
def command(text):os.write(master,text.encode()+b'\n');return until_prompt()
def wait_for(predicate):
    deadline=time.monotonic()+5
    while not predicate():
        assert time.monotonic()<deadline,'terminal state did not change';time.sleep(0.01)
def state(process):
    try:return (Path('/proc')/str(process)/'stat').read_text().rsplit(') ',1)[1].split()[0]
    except FileNotFoundError:return None
try:
    until_prompt();assert os.tcgetpgrp(master)==pid
    os.write(master,b'sleep 60 | cat\n')
    wait_for(lambda:os.tcgetpgrp(master)!=pid)
    group=os.tcgetpgrp(master);known_groups.add(group)
    os.write(master,b'\x1a');output=until_prompt()
    assert 'Stopped' in output and os.tcgetpgrp(master)==pid,output
    assert 'Stopped' in command('jobs -l')
    print('Ctrl-Z stops the foreground pipeline and returns the terminal to the interactive shell')
    command('bg %1');assert os.tcgetpgrp(master)==pid
    os.write(master,b'fg %1\n');wait_for(lambda:os.tcgetpgrp(master)==group)
    os.write(master,b'\x03');until_prompt()
    assert 'RESULT:130' in command("printf 'RESULT:%s\\n' \"$?\"")
    print('bg resumes without foreground ownership; fg restores ownership; Ctrl-C gives this pipeline status 130')
    output=command("cat & printf 'BG:%s\\n' \"$!\"")
    background=int(re.search(r'BG:(\d+)',output)[1]);known_groups.add(background)
    wait_for(lambda:state(background)=='T')
    assert os.tcgetpgrp(master)==pid
    assert 'Stopped (tty input)' in command('jobs -l')
    print('a background reader is stopped by terminal input rules, rather than receiving the shell keyboard stream')
    os.write(master,b'fg %+\n');wait_for(lambda:os.tcgetpgrp(master)==background)
    os.write(master,b'hello\n\x04');output=until_prompt();assert 'hello' in output,output
    assert 'RESULT:0' in command("printf 'RESULT:%s\\n' \"$?\"")
    print('foregrounding that reader lets it read; EOF ends it normally; all jobs are reaped')
    assert not command('jobs -p').strip()
    os.write(master,b'exit\n');waited,status=os.waitpid(pid,0);assert waited==pid and os.WIFEXITED(status)
    pid=None
finally:
    if pid is not None:
        for group in known_groups:
            try:os.killpg(group,signal.SIGKILL)
            except ProcessLookupError:pass
        os.waitpid(pid,0)
    os.close(master)

The fixture enables terminal signal processing and canonical input, explicitly assigns interrupt, suspend, and EOF characters, and disables input echo. It starts Bash without user startup files and uses a recognizable prompt. These choices make the experiment reproducible without relying on a user’s aliases, prompt commands, or customized terminal key bindings.

Bash first runs sleep 60 | cat in the foreground. The controller observes the terminal’s foreground PGID change from Bash to the pipeline. Sending the suspend character stops the job, produces a Stopped report, and returns foreground ownership to Bash. jobs -l confirms that the shell still knows the stopped job.

bg %1 resumes the pipeline while Bash retains the terminal. fg %1 then gives foreground ownership back to the pipeline. Sending the interrupt character ends this default-behavior pipeline, and Bash reports status 130. This is an observed Bash status for this command, not a promise that every application must exit on INT or return the same value.

The script next launches a background cat, records its PID, and waits for a stopped process state. Bash reports Stopped (tty input). This is a terminal-input restriction, not completion and not evidence that the reader consumed the shell’s next command. The shell remains the terminal’s foreground group throughout this background attempt.

Foregrounding that reader lets it receive hello. An EOF character at an empty canonical input line allows it to finish normally, after which Bash reports zero and its job table is empty. The controller then exits Bash and reaps it. Failure cleanup is limited to the isolated groups created by the experiment.

Choose the intended lifetime and input source

Use bg when a stopped job can proceed without interactive terminal input. Use fg when it needs the terminal again. Redirecting input to a suitable file or pipe can let background work run independently of keyboard input, but it does not automatically detach the process from its session or define what happens when the login ends.

Use a service manager for workloads whose lifetime must outlive an interactive session and whose restart, logging, and resource policies must be explicit. A shell job table is useful for current interactive work; it is not durable service state. Disowning a job or changing hangup handling also answers narrower questions than full service supervision.

When a job seems frozen, inspect whether it is stopped, waiting for data, or still computing before sending termination signals. Correlate the job specification, PID, PGID, and terminal foreground group. This prevents confusing a legitimate terminal-input stop with a crashed process and helps restore the intended interaction without unnecessarily discarding the work.

Sources

  • Bash job-control basics, foreground groups, terminal input, suspension, and job states, checked 2026-09-12.
  • Bash job-control builtins, jobs, bg, fg, and shell-local job specifications, checked 2026-09-12.
  • termios(3), terminal control characters and signal generation, checked 2026-09-12.
  • tcgetpgrp(3), reading and selecting the terminal foreground process group, checked 2026-09-12.