152. Process Signals (App V1.1)
RAM Manager v1.1 — AI-guided process termination
152. Process Signals (App V1.1)
“The AI told me to close Chrome. I want Python to do it for me.”
🤔 Socratic question: When you Force Quit an app on macOS, or End Task on Windows, what actually happens at the OS level? How does the OS force a program to stop without the program’s cooperation? This is signal theory — and it’s beautiful in a brutal way.
🏛️ History: Signals were designed as part of the original Unix specification in 1969. The entire signal system — SIGTERM, SIGKILL, SIGHUP, SIGINT — was designed by Ken Thompson and Dennis Ritchie. When you press Ctrl+C to stop a Python script, you’re triggering SIGINT, a mechanism designed in 1969. Some code never dies.
Before writing the code, Aryan needs to understand signals.
Process Signals: How Programs Talk to Each Other 📡
A signal is a message the OS sends directly to a process. The process can handle it, ignore it, or be killed by it.
kill -l # list all signals
# 1) SIGHUP 2) SIGINT 3) SIGQUIT
# 15) SIGTERM 9) SIGKILLYou’ve used signals before:
Ctrl+Csends SIGINT — interrupt (usually terminates)Ctrl+Zsends SIGTSTP — suspend (sends to background)kill PIDsends SIGTERM by default
💡 Every time you Ctrl+C a Python script, you’re sending SIGINT. The KeyboardInterrupt exception Python raises? That’s just Python’s way of handling SIGINT.
SIGTERM vs SIGKILL ⚔️
| SIGTERM (15) | SIGKILL (9) | |
|---|---|---|
| Can be caught by process? | Yes | No |
| Process can clean up? | Yes | No |
| Databases flush writes? | Yes | No |
| Always works? | No (can be ignored) | Yes |
SIGTERM = “please exit” — the polite way ☕ SIGKILL = “you will die now” — the nuclear option ☢️
kill 812 # SIGTERM (default)
kill -TERM 812 # same thing
kill -9 812 # SIGKILL — last resort💀 Culture:
kill -9has such a following in dev culture that you’ll findkill -9stickers at hackathons, people withkill -9tattooed on them, and entire Reddit threads debating when it’s acceptable. Spoiler: almost never in production code.
The Graceful Shutdown Pattern 🕊️
Always try SIGTERM first. Escalate to SIGKILL only after a timeout.
import psutil
def kill_process(pid: int) -> bool:
try:
proc = psutil.Process(pid)
proc.terminate() # SIGTERM — polite
try:
proc.wait(timeout=3) # wait up to 3 seconds
except psutil.TimeoutExpired:
proc.kill() # SIGKILL — force quit
return True
except psutil.NoSuchProcess:
return False # already gone
except psutil.AccessDenied:
return False # no permission💡 Real-world: Kubernetes (the system that runs most of the internet’s containers) uses exactly this pattern. When it needs to shut down a container, it sends SIGTERM first, waits 30 seconds, then sends SIGKILL. This is called the “graceful shutdown period” and it’s in every production system.
What Happens to Child Processes? 👶
When a parent process receives SIGKILL, it dies instantly. Its children become orphans — they keep running, reparented to PID 1 (init/launchd).
This is why proc.terminate() (SIGTERM) is better:
- A well-behaved parent sends SIGTERM to its children before exiting
- Children clean up their own resources
- No orphan processes left behind
For Chrome: killing the main process may leave tab renderer processes running. Use pkill chrome (by name) to kill the whole family.
🤯 Mind-blown: Chrome runs EACH TAB as a separate process. That’s why when a tab crashes it doesn’t crash the whole browser. And it’s why Chrome uses so much RAM — each process needs its own memory space. Google made this architectural decision for stability. You’re now equipped to understand it.
Permissions: Who Can Kill Whom? 🔐
You can only kill processes you own (same user ID).
$ kill 1 # can't kill launchd/init
# bash: kill: (1) - Operation not permitted
$ kill 812 # can kill your own Chrome
# (success)In Python: psutil.AccessDenied tells you when you lack permission. Always catch it — never crash on a permission error.
💡 Fun fact: The proc.wait(timeout=3) call uses the OS waitpid() syscall under the hood — the same mechanism the OS uses to reap zombie processes. When a process exits, it doesn’t fully disappear until its parent calls waitpid(). That’s why orphan processes that outlive their parent get reparented to PID 1 (init/launchd) — so init can reap them when they eventually die. psutil hides all of this from you.
⚠️ Watch out: Calling os.kill(pid, signal.SIGKILL) with no try/except raises ProcessLookupError if the PID doesn’t exist — not psutil.NoSuchProcess. If you mix os.kill and psutil in the same function, you need to catch BOTH exception types. The psutil.Process(pid).kill() API is safer because it raises the consistent psutil.NoSuchProcess everywhere. Pick one API and stick to it.
🤔 Think about it: kill_process() returns a bool — but monitor_and_kill() in the broken code ignores it. Why does this matter? If the kill silently fails (because of AccessDenied), the user sees “Killed chrome.” — but chrome is still running. What’s the name for this bug pattern where you call a function that returns an error status but discard it? (Hint: it’s called “unchecked return value” and it’s a real CWE vulnerability category.)
🔫 Aryan upgrades the RAM manager to act on AI advice. When RAM is high, the LLM picks the top offending process and the manager asks the user to confirm before killing it. He sends SIGKILL straight away (skipping SIGTERM), doesn’t wait for the process to exit, and crashes on permission errors.
Learning objectives
- Use proc.terminate() (SIGTERM) before proc.kill() (SIGKILL)
- Wait for process exit with proc.wait(timeout=N)
- Handle TimeoutExpired to escalate to force-kill
- Catch NoSuchProcess and AccessDenied gracefully
Key concepts
- proc.terminate() — sends SIGTERM
- proc.kill() — sends SIGKILL (force)
- proc.wait(timeout=N) — waits for exit, raises TimeoutExpired
- psutil.NoSuchProcess — process already gone
- psutil.AccessDenied — no permission to send signal
Try it
Concept detail
App v1.1 — AI-guided process termination
$ python ram_manager.py
RAM: 91.2%
AI suggests killing: chrome (PID 812, 2100 MB)
Kill it? [y/N] y
Killed chrome.Graceful shutdown pattern
proc = psutil.Process(pid)
proc.terminate() # SIGTERM — "please exit"
try:
proc.wait(timeout=3) # wait up to 3 seconds
except psutil.TimeoutExpired:
proc.kill() # SIGKILL — "exit NOW"Why SIGTERM before SIGKILL?
SIGTERM lets the process save its state, flush buffers, and exit cleanly. SIGKILL is instant but may leave temp files, corrupt open databases, or leave child processes orphaned. Always try SIGTERM first.
Solution
import os
import signal
import time
import psutil
import requests
from dotenv import load_dotenv
load_dotenv()
def take_snapshot(n: int = 10) -> dict:
mem = psutil.virtual_memory()
processes = []
for proc in psutil.process_iter(['pid', 'name', 'memory_info']):
try:
rss_mb = proc.info['memory_info'].rss / 1e6
processes.append({'name': proc.info['name'], 'pid': proc.info['pid'], 'rss_mb': rss_mb})
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return {
'percent': mem.percent,
'used_gb': mem.used / 1e9,
'total_gb': mem.total / 1e9,
'processes': sorted(processes, key=lambda p: p['rss_mb'], reverse=True)[:n],
}
def ask_llm_which_to_kill(snapshot: dict) -> dict | None:
api_key = os.environ.get('ANTHROPIC_API_KEY')
if not api_key:
raise EnvironmentError('ANTHROPIC_API_KEY not set')
top = snapshot['processes'][:5]
names = ', '.join(f"{p['name']}({p['rss_mb']:.0f}MB)" for p in top)
prompt = (
f"RAM at {snapshot['percent']:.1f}%. "
f"Top processes: {names}. "
f"Reply with just the name of ONE process to kill, nothing else."
)
response = requests.post(
'https://api.anthropic.com/v1/messages',
json={'model': 'claude-haiku-4-5-20251001', 'max_tokens': 50,
'messages': [{'role': 'user', 'content': prompt}]},
headers={'x-api-key': api_key, 'anthropic-version': '2023-06-01'},
timeout=30,
)
response.raise_for_status()
name = response.json()['content'][0]['text'].strip()
for p in top:
if p['name'].lower() == name.lower():
return p
return top[0] if top else None
def kill_process(pid: int) -> bool:
'''Gracefully terminate a process: SIGTERM first, SIGKILL if needed.'''
try:
proc = psutil.Process(pid)
proc.terminate() # SIGTERM — polite request to exit
try:
proc.wait(timeout=3) # give it 3 seconds
except psutil.TimeoutExpired:
proc.kill() # SIGKILL — force quit
return True
except psutil.NoSuchProcess:
return False # already gone
except psutil.AccessDenied:
print(f'Permission denied — cannot kill PID {pid}')
return False
def monitor_and_kill(threshold: int = 80) -> None:
snapshot = take_snapshot()
pct = snapshot['percent']
print(f'RAM: {pct:.1f}%')
if pct <= threshold:
print('RAM is fine.')
return
target = ask_llm_which_to_kill(snapshot)
if not target:
return
print(f"\nAI suggests killing: {target['name']} (PID {target['pid']}, {target['rss_mb']:.0f} MB)")
answer = input('Kill it? [y/N] ').strip().lower()
if answer != 'y':
print('Skipped.')
return
ok = kill_process(target['pid'])
if ok:
print(f"Killed {target['name']}.")
else:
print(f"Could not kill {target['name']}.")
if __name__ == '__main__':
monitor_and_kill()Tests
import signal
import psutil
from unittest.mock import patch, MagicMock, call
def test_kill_process_uses_terminate_first():
import inspect
src = inspect.getsource(kill_process)
assert 'terminate()' in src, 'Call proc.terminate() before proc.kill()'
# terminate must appear before kill in the source
assert src.index('terminate()') < src.index('kill()'), \
'terminate() must come before kill()'
def test_kill_process_handles_nosuchprocess():
with patch('psutil.Process') as mock_proc_cls:
mock_proc_cls.side_effect = psutil.NoSuchProcess(pid=99999)
result = kill_process(99999)
assert result is False, 'Return False when process does not exist'
def test_kill_process_handles_access_denied():
with patch('psutil.Process') as mock_proc_cls:
mock_proc = MagicMock()
mock_proc.terminate.side_effect = psutil.AccessDenied(pid=1)
mock_proc_cls.return_value = mock_proc
result = kill_process(1)
assert result is False, 'Return False when permission denied'
def test_kill_process_returns_true_on_success():
with patch('psutil.Process') as mock_proc_cls:
mock_proc = MagicMock()
mock_proc.wait.return_value = None
mock_proc_cls.return_value = mock_proc
result = kill_process(12345)
assert result is True
def test_kill_process_sigkill_on_timeout():
import inspect
src = inspect.getsource(kill_process)
assert 'TimeoutExpired' in src, 'Handle TimeoutExpired to escalate to SIGKILL'
assert 'kill()' in src, 'Call proc.kill() as fallback after timeout'