Process Wrangling on macOS and Linux

2026/02/19

BUILDinfra🍞

I killed a process. It came back. I killed it again. It came back again. Turns out the process was managed by launchd – macOS’s init system – and killing it was like pulling a weed without getting the root. The system just restarted it.

This sent me down the rabbit hole of “how do processes actually work on this machine.” Here’s everything I wish I’d had on one page.

Finding processes

Before you can kill something, you have to find it.

ps aux – the classic

ps aux | grep openclaw

This lists every running process on the system. The columns that matter:

USER       PID  %CPU %MEM    VSZ   RSS TTY  STAT STARTED   TIME COMMAND
shiyuan  12345  0.3  1.2 420000 48000   ??  S    10:30AM  0:02  /usr/local/bin/openclaw gateway

Tip: ps aux | grep something always matches itself (the grep process). You’ll see a line like grep --color=auto openclaw. Ignore it, or use pgrep instead.

pgrep – just give me the PID

pgrep openclaw          # PIDs only
pgrep -fl openclaw      # PIDs + full command line

Cleaner than ps aux | grep. No self-match noise. The -f flag matches against the full command line, not just the process name – useful when the binary is named something generic but the arguments are distinctive.

top and htop – live view

top                     # built-in, every system has it
htop                    # much better UI, install via brew/apt

top is the Activity Monitor of the terminal. It shows CPU/memory usage in real time, sorted by resource consumption. Press q to quit, P to sort by CPU, M to sort by memory.

htop is top but actually usable – color-coded, mouse-friendly, tree view of parent/child processes, and you can kill processes directly from the UI with F9. If you don’t have it:

brew install htop       # macOS
sudo apt install htop   # Ubuntu/Debian

Killing processes

kill – send a signal

kill 12345              # sends SIGTERM (signal 15) -- "please exit gracefully"
kill -9 12345           # sends SIGKILL (signal 9) -- "die now, no cleanup"

kill doesn’t mean “kill.” It means “send a signal.” kill 12345 sends SIGTERM, which asks the process to shut down. The process can catch SIGTERM, clean up resources, save state, and exit. Most well-behaved programs do this.

kill -9 sends SIGKILL, which the process cannot catch or ignore. The kernel terminates it immediately. Use this when a process is stuck and won’t respond to SIGTERM. But know the tradeoff: no cleanup means potential data corruption, leaked temp files, orphaned lock files.

My rule: Try kill first. Wait a couple seconds. If it’s still there, then kill -9.

killall – by name

killall openclaw        # kill all processes named "openclaw"

Matches on the process name (not the full command line). Convenient when you don’t want to look up PIDs. Be careful – if you run killall python, you’ll kill every Python process on the machine.

pkill – pattern matching

pkill openclaw          # kill processes matching "openclaw"
pkill -f "openclaw gateway"  # match against full command line

pkill is the kill counterpart to pgrep. The -f flag matches against the full command line, same as pgrep -f. More flexible than killall since it does substring matching.

The process that won’t stay dead – launchd

This is where I got stuck. I ran kill, confirmed the process was gone, and five seconds later it was back. That’s launchd.

What is launchd?

launchd is macOS’s process supervisor – it’s PID 1, the first process that starts when your Mac boots, and the parent of everything else. It manages background services (daemons) and per-user agents. If a managed process dies, launchd restarts it. That’s its job.

This is why kill doesn’t work on managed processes. You’re fighting the supervisor. It’s like trying to close a door while someone on the other side keeps opening it.

launchctl list – what’s launchd managing?

launchctl list | grep openclaw

Output looks like:

12345  0  com.openclaw.gateway

The columns: PID, last exit status, label. The label is the service identifier – you’ll use it for every other launchctl command.

Stopping a managed service

There are multiple ways, and they do different things:

# Stop the service (it may restart based on plist config)
launchctl stop com.openclaw.gateway

# Unload the service definition (prevents restart, deprecated but still works)
launchctl unload ~/Library/LaunchAgents/com.openclaw.gateway.plist

# Modern equivalent of unload -- removes it from the boot session
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.openclaw.gateway.plist

launchctl stop sends a stop signal but leaves the service registered. If the plist has KeepAlive set to true, launchd will restart it.

launchctl bootout (or the older launchctl unload) removes the service from the current session. It won’t restart until the next login or until you explicitly reload it. This is what you want when you’re trying to actually stop something.

Where plist files live

Services are defined by .plist files in specific directories:

Directory Scope Runs as
~/Library/LaunchAgents/ Current user only Your user
/Library/LaunchAgents/ All users, on login Each user
/Library/LaunchDaemons/ System-wide, on boot root (usually)
/System/Library/Launch* Apple’s own services Don’t touch these

If something keeps respawning and you can’t find it in ~/Library/LaunchAgents/, check /Library/LaunchDaemons/. It might be a system-level daemon.

Reading a plist

cat ~/Library/LaunchAgents/com.openclaw.gateway.plist

The important keys:

<dict>
    <key>Label</key>
    <string>com.openclaw.gateway</string>

    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/openclaw</string>
        <string>gateway</string>
        <string>start</string>
    </array>

    <key>KeepAlive</key>
    <true/>                    <!-- THIS is why it keeps coming back -->

    <key>RunAtLoad</key>
    <true/>                    <!-- starts automatically on login -->
</dict>

KeepAlive: true is the culprit. It tells launchd “if this process dies for any reason, restart it.” The only way to stop it is to remove it from launchd entirely with bootout, or edit the plist.

The full “make it stop” sequence

# 1. Find the service
launchctl list | grep openclaw

# 2. Remove it from the current session
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.openclaw.gateway.plist

# 3. Verify it's gone
pgrep openclaw          # should return nothing
launchctl list | grep openclaw  # should return nothing

# 4. (Optional) Prevent it from starting on next login
# Either delete the plist or set RunAtLoad to false

Linux systemd – the same idea, different commands

Linux’s equivalent of launchd is systemd. Same concept: a process supervisor that manages services, restarts them on failure, and starts them on boot. Different commands.

# Check service status
systemctl status openclaw

# Stop a service
systemctl stop openclaw

# Prevent it from starting on boot
systemctl disable openclaw

# Stop AND prevent boot start
systemctl disable --now openclaw

# See logs
journalctl -u openclaw -f       # -f for live tail

The key difference: systemd has a clean separation between “running right now” (stop/start) and “starts on boot” (enable/disable). launchd mixes these in the plist file.

I want to… macOS (launchctl) Linux (systemctl)
See if it’s running launchctl list | grep name systemctl status name
Stop it now launchctl bootout gui/$(id -u) /path/to.plist systemctl stop name
Prevent boot start Remove or edit the plist systemctl disable name
View logs log show --predicate 'process == "name"' journalctl -u name
Start it launchctl bootstrap gui/$(id -u) /path/to.plist systemctl start name

Finding what’s using a port

“Something is already listening on port 8080.” This happens all the time.

# macOS
lsof -i :8080

# Linux
lsof -i :8080
# or
ss -tlnp | grep 8080

lsof stands for “list open files” – and in Unix, network sockets are files. -i :8080 means “show me everything using port 8080.”

COMMAND   PID    USER   FD   TYPE  DEVICE SIZE/OFF NODE NAME
node    14523  shiyuan  22u  IPv4  0x1234      0t0  TCP *:8080 (LISTEN)

Now you know it’s a node process with PID 14523. Kill it, or realize you forgot to stop your dev server from yesterday.

Common lsof patterns

lsof -i :8080           # what's on port 8080?
lsof -i -P -n           # all network connections (fast, no DNS lookup)
lsof -p 12345           # all files opened by PID 12345
lsof +D /var/log        # what's reading/writing in this directory?

Background processes – jobs, nohup, disown

Sometimes you want to start something and forget about it.

The basics

# Run in background (attached to terminal)
./long-running-script.sh &

# See background jobs for this shell
jobs

# Bring job #1 back to foreground
fg %1

# Send foreground job to background (Ctrl+Z first, then:)
bg %1

The & at the end runs a command in the background, but it’s still tied to your terminal session. Close the terminal and the process dies.

nohup – survive terminal close

nohup ./long-running-script.sh &

nohup means “no hangup.” When you close a terminal, it sends SIGHUP (hangup signal) to all its child processes. nohup makes the process ignore that signal. Output goes to nohup.out by default.

# With explicit output redirect
nohup ./script.sh > output.log 2>&1 &

disown – retroactive nohup

You forgot to use nohup and the process is already running:

./long-running-script.sh &    # oops, forgot nohup
disown %1                     # detach it from the shell

disown removes the job from the shell’s job table. The shell won’t send SIGHUP when it exits. The process lives on.

The hierarchy:

Quick reference

I want to… Command
Find a process by name pgrep -fl name
See all processes ps aux or htop
Kill gracefully kill PID
Kill forcefully kill -9 PID
Kill by name pkill name
Find what’s on a port lsof -i :PORT
Stop a macOS daemon launchctl bootout gui/$(id -u) /path/to.plist
Stop a Linux service systemctl stop name
Run and forget nohup ./script.sh &
Detach running job disown %1

Most of the time you just need pgrep, kill, and lsof. But when a process won’t die, knowing about launchd/systemd is the difference between a five-second fix and twenty minutes of confused googling.