← Home

151. Os.Kill + Signal + Psutil Process Control

Killing processes safely with signals

151. Os.Kill + Signal + Psutil Process Control

“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 graceful shutdown timeout in Kubernetes is 30 seconds by default, but teams configure it based on how long their app needs to drain connections and finish in-flight requests. Some databases need minutes to flush WAL (Write-Ahead Log) buffers safely. This is why kill -9 on a database can corrupt data — it skips the flush entirely.

⚠️ Watch out: os.kill(pid, signal.SIGKILL) has no try/except and raises ProcessLookupError if the PID doesn’t exist. psutil.Process(pid).kill() still raises psutil.NoSuchProcess. Always wrap process operations in try/except for both exceptions. A process can exit between the time you list it and the time you try to signal it.

🤔 Think about it: Why does os.kill(pid, 0) not actually kill the process? What does signal 0 mean? If psutil.Process(pid).is_running() is the safer alternative — why is it safer? What race condition does checking pid then calling kill expose you to?


⚡ Aryan wants the RAM manager to actually kill offending processes when the AI recommends it. He uses signal.SIGKILL (force kill) when he should start with SIGTERM (polite ask), doesn’t check if the process exists first, and uses os.kill when psutil.Process gives a cleaner cross-platform API.

Learning objectives

  • Use SIGTERM before SIGKILL (polite before force)
  • Use psutil.Process.terminate() and .kill() for cross-platform
  • Catch NoSuchProcess and AccessDenied — processes can die at any time
  • Use os.kill(pid, 0) or psutil to check if a process is running
  • [object Object]

Key concepts

  • SIGTERM — polite shutdown request (signal 15)
  • SIGKILL — instant force kill (signal 9, uncatchable)
  • proc.terminate() — send SIGTERM via psutil
  • proc.kill() — send SIGKILL via psutil
  • proc.wait(timeout=N) — wait for exit, TimeoutExpired if slow
  • NoSuchProcess — process already gone

Try it

Concept detail

Killing Processes Safely

Shell commands (the quick way)

kill 812            # SIGTERM to PID 812 (polite)
kill -9 812         # SIGKILL to PID 812 (force)
kill -SIGTERM 812   # explicit name

pkill chrome        # SIGTERM to all processes named chrome
pkill -9 chrome     # SIGKILL
killall chrome      # same as pkill on most systems

pgrep chrome        # list PIDs named chrome
pgrep -a chrome     # list PIDs + full command line

Signals explained

SignalNumberMeaning
SIGTERM15Please stop (can be caught/ignored)
SIGKILL9Stop NOW (cannot be caught)
SIGHUP1Terminal closed / reload config
SIGINT2Ctrl+C
import psutil

proc = psutil.Process(pid)
proc.terminate()              # SIGTERM
try:
    proc.wait(timeout=3)      # wait up to 3 seconds
except psutil.TimeoutExpired:
    proc.kill()               # SIGKILL — last resort

# By name
for proc in psutil.process_iter(['pid', 'name']):
    try:
        if proc.info['name'] == 'chrome':
            proc.terminate()
    except (psutil.NoSuchProcess, psutil.AccessDenied):
        pass

Python: os.kill (lower level)

import os, signal

os.kill(pid, signal.SIGTERM)   # polite
os.kill(pid, signal.SIGKILL)   # force
os.kill(pid, 0)                # check if PID exists (no-op signal)

Safe pattern: SIGTERM → wait → SIGKILL

def safe_kill(pid: int):
    try:
        proc = psutil.Process(pid)
        proc.terminate()          # try politely first
        proc.wait(timeout=5)
    except psutil.TimeoutExpired:
        proc.kill()               # force if still alive
    except psutil.NoSuchProcess:
        pass                      # already gone, that's fine

Solution

import os
import signal
import time
import psutil

def kill_process(pid: int, force: bool = False) -> bool:
    '''Send SIGTERM, wait 3s, then SIGKILL if still running.'''
    try:
        proc = psutil.Process(pid)
    except psutil.NoSuchProcess:
        return False   # already gone

    if force:
        proc.kill()    # SIGKILL immediately
        return True

    proc.terminate()   # SIGTERM — polite, lets process clean up
    try:
        proc.wait(timeout=3)   # wait up to 3 seconds
    except psutil.TimeoutExpired:
        proc.kill()            # still alive — force kill
    return True

def terminate_by_name(name: str) -> list[int]:
    killed = []
    for proc in psutil.process_iter(['pid', 'name']):
        try:
            if proc.info['name'] == name:
                proc.terminate()
                killed.append(proc.info['pid'])
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            pass   # process died or no permission — skip
    return killed

def is_process_running(pid: int) -> bool:
    try:
        proc = psutil.Process(pid)
        return proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE
    except psutil.NoSuchProcess:
        return False

Tests

import psutil
import pytest
from unittest.mock import MagicMock, patch

def test_is_process_running_invalid_pid():
    assert is_process_running(999999999) is False

def test_kill_process_nonexistent_returns_false():
    result = kill_process(999999999)
    assert result is False

def test_kill_process_uses_terminate_not_kill():
    import inspect
    src = inspect.getsource(kill_process)
    assert 'terminate()' in src, 'Use .terminate() (SIGTERM) before .kill() (SIGKILL)'

def test_kill_process_uses_wait_before_kill():
    import inspect
    src = inspect.getsource(kill_process)
    assert 'wait(' in src, 'Call proc.wait(timeout=N) after terminate() before escalating to kill()'

def test_terminate_by_name_handles_nosuchprocess():
    # should not raise even if processes die during iteration
    result = terminate_by_name('definitely_not_a_real_process_xyz')
    assert result == []

def test_kill_process_returns_true_on_success():
    mock_proc = MagicMock()
    mock_proc.wait.return_value = None   # exits cleanly within timeout
    with patch('psutil.Process', return_value=mock_proc):
        result = kill_process(12345)
    assert result is True
    mock_proc.terminate.assert_called_once()

def test_kill_process_escalates_to_kill_on_timeout():
    mock_proc = MagicMock()
    mock_proc.wait.side_effect = psutil.TimeoutExpired(seconds=3, pid=12345)
    with patch('psutil.Process', return_value=mock_proc):
        result = kill_process(12345)
    assert result is True
    mock_proc.terminate.assert_called_once()
    mock_proc.kill.assert_called_once()   # escalated because wait timed out

def test_is_process_running_returns_false_for_zombie():
    mock_proc = MagicMock()
    mock_proc.is_running.return_value = True
    mock_proc.status.return_value = psutil.STATUS_ZOMBIE
    with patch('psutil.Process', return_value=mock_proc):
        assert is_process_running(12345) is False

def test_is_process_running_returns_true_for_live_process():
    mock_proc = MagicMock()
    mock_proc.is_running.return_value = True
    mock_proc.status.return_value = psutil.STATUS_RUNNING
    with patch('psutil.Process', return_value=mock_proc):
        assert is_process_running(12345) is True

Resources