← Home

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) SIGKILL

You’ve used signals before:

  • Ctrl+C sends SIGINT — interrupt (usually terminates)
  • Ctrl+Z sends SIGTSTP — suspend (sends to background)
  • kill PID sends 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?YesNo
Process can clean up?YesNo
Databases flush writes?YesNo
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 -9 has such a following in dev culture that you’ll find kill -9 stickers at hackathons, people with kill -9 tattooed 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'

Resources