Skip to content

17 medium child process leakage

Part II: Execution & Reliability | Challenge §17

17. Child Process Leakage

Severity: Medium | Frequency: Situational | Detectability: Hard | Token Spend: Low | Time: Low | Context: Low

The Problem

CLI tools frequently spawn background processes — log forwarders, file watchers, health monitors, connection pools. When the main CLI process exits, these children are often orphaned, accumulating over time and consuming resources.

Orphaned children from normal exit:

$ tool start-watcher --dir /project
# Spawns: inotify daemon (PID 5678)
# Main process exits: 0
# inotify daemon: still running, never cleaned up

# After 100 agent calls: 100 inotify daemons running

Children that hold locks:

$ tool sync
# Spawns background sync process that holds /var/lock/tool.lock
# Main process exits
# Lock never released
# Next `tool sync` call: "Error: lock held by PID 5678 (defunct)"

Children that write to stdout after parent exits:

$ tool deploy
# Spawns background health-check process
# Main process exits, prints JSON result
# 2 seconds later: background process prints "health: ok" to stdout
# Agent's JSON parse of captured output now fails (extra text after valid JSON)

Signal not forwarded to children:

$ kill -TERM $TOOL_PID
# Tool exits cleanly
# But spawned children never received SIGTERM
# They run forever as orphans owned by init/PID1

Impact

  • Resource exhaustion: file descriptors, memory, CPU from accumulated orphans
  • Lock files held by dead-parent children block all future invocations
  • Stdout corruption from children writing after parent exits
  • Containers/VMs accumulate ghost processes that survive restarts

Solutions

Process group management:

import os, signal, atexit

# Start child in its own process group
child = subprocess.Popen(
    ["child-process"],
    start_new_session=True,   # new process group
    preexec_fn=os.setsid
)

# On exit, kill the entire process group
def cleanup():
    try:
        os.killpg(os.getpgid(child.pid), signal.SIGTERM)
    except ProcessLookupError:
        pass  # already dead

atexit.register(cleanup)
signal.signal(signal.SIGTERM, lambda *_: (cleanup(), sys.exit(143)))

PID file tracking:

$ tool start-watcher --dir /project
# Writes child PIDs to: /tmp/tool-session-42/children.pids
# Provides cleanup command:
{
  "ok": true,
  "background_pid": 5678,
  "cleanup_command": "tool stop-watcher --session 42",
  "pid_file": "/tmp/tool-session-42/children.pids"
}

Mandatory child tracking in schema:

{
  "command": "start-watcher",
  "spawns_background_process": true,
  "cleanup_command": "stop-watcher",
  "max_lifetime_seconds": 3600
}

Stdout close before spawning detached children:

# If spawning a truly detached daemon:
# 1. Complete all stdout writes and flush
# 2. Close stdout
# 3. Only then fork the background process
sys.stdout.flush()
sys.stdout.close()
child = subprocess.Popen(["daemon"], close_fds=True, stdin=DEVNULL,
                          stdout=DEVNULL, stderr=DEVNULL)

For framework design: - Framework tracks all child PIDs in a session file automatically - tool session cleanup --id $SESSION kills all tracked children - Commands that spawn background processes must declare spawns_background_process: true - Framework installs a SIGTERM handler that forwards to all tracked children before exit

Evaluation

Score Condition
0 Background children orphaned on exit; stdout receives child output after parent exits; locks held by orphans
1 Cleanup command documented but not declared in output; SIGTERM not forwarded to children
2 Cleanup command returned in JSON output; SIGTERM forwarded to tracked children
3 All children tracked in session-scoped PID file; tool session cleanup kills all; spawns_background_process declared in manifest

Check: Invoke a command that spawns background processes, note the child PIDs from the output, let the parent exit, then verify child PIDs are no longer running.


Agent Workaround

Signature: stray processes remain after the CLI exits 0, accumulating per call; next run fails with lock held by PID; extra lines appear on stdout after the final JSON

Tier: C (stateful logic; weak models apply the fallback below) Fallback: run the cleanup_command reported in the previous JSON output; if that fails, escalate with the command, exit code, stdout, and stderr

Track background PIDs from command output and clean them up before the next invocation:

result = run(["tool", "start-watcher", "--dir", "/project"])
parsed = json.loads(result.stdout)

background_pid = parsed.get("background_pid")
cleanup_cmd = parsed.get("cleanup_command")
pid_file = parsed.get("pid_file")

# Register cleanup to run before next invocation or on agent exit
import atexit, os, signal

def cleanup_children():
    if cleanup_cmd:
        run(cleanup_cmd.split())  # use declared cleanup command
    elif background_pid:
        try:
            os.kill(background_pid, signal.SIGTERM)
        except ProcessLookupError:
            pass  # already dead

atexit.register(cleanup_children)

Kill the entire process group to catch all descendants:

import os, signal
# If you know the child's PID, kill its entire process group
try:
    os.killpg(os.getpgid(background_pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
    pass

Limitation: If the tool does not declare background_pid or cleanup_command in its JSON output, the agent has no reliable way to track orphaned children — check for lingering lock files before the next invocation and alert if they exist