Skip to content

Process leak: wakatime-cli processes never exit when AI agent batch-edits files, causing massive memory usage #73

@Color2333

Description

@Color2333

Bug Description

When opencode's AI agent performs batch file edits (e.g., multiedit, patch, or rapid sequential edits), multiple wakatime-cli processes are spawned but never exit. These zombie processes accumulate, each consuming ~1.6GB of physical memory and spinning at high CPU.

Environment

  • opencode-wakatime version: 1.3.0
  • wakatime-cli version: v2.13.0
  • opencode version: 1.14.39
  • OS: macOS Darwin 26.3.1 (arm64)

Reproduction Steps

  1. Start opencode with the wakatime plugin enabled
  2. Have the AI agent perform batch file edits (e.g., a multiedit across 7+ files)
  3. Observe that wakatime-cli processes are spawned but never exit

Observed Behavior

  • 7+ wakatime-cli processes stuck in running state (R+) for 6+ minutes
  • Each process: ~1.6GB physical memory (peak 1.9GB), 23-183% CPU
  • Total memory: ~11GB leaked
  • Processes are children of opencode (PID 16448), communicating via unix pipes
  • Parent process stops reading from pipes, but child processes never exit

Root Cause Analysis

Issue 1: No timeout or kill mechanism in sendHeartbeat()

In src/wakatime.ts, the sendHeartbeat function uses execFile to spawn wakatime-cli:

export function sendHeartbeat(params: HeartbeatParams): Promise<void> {
  return new Promise((resolve) => {
    execFile(cliLocation, args, execOptions, (error, stdout, stderr) => {
      resolve();
    });
  });
}

The Promise only resolves when the child process exits. If wakatime-cli hangs, the Promise never resolves and the process leaks. There is no timeout, no child.kill(), and no cleanup mechanism.

Issue 2: Fire-and-forget heartbeats

In src/index.ts, processHeartbeat() is called after every tool execution:

// When force=false (default), promises are NOT awaited
const promise = sendHeartbeat({...});
if (force) {
  heartbeatPromises.push(promise);
}

When force=false (the normal case), heartbeat promises are fired and forgotten. If the child process hangs, it leaks. The fileChanges map is cleared immediately, so there's no way to detect or recover from stuck processes.

Issue 3: --project-folder / points to root directory

The command line shows --project-folder / which points to the filesystem root. This may cause wakatime-cli to recursively scan the entire filesystem, contributing to the 1.6GB memory usage and hang.

Process Sample

sample output shows the main thread stuck in kqueue event loop:

  • Main thread: kevent busy-waiting
  • Another thread: pthread_cond_wait + pthread_kill cycling
  • This is a Go runtime goroutine leak / busy-wait pattern

Expected Behavior

  • wakatime-cli processes should exit within seconds
  • If a process hangs, it should be killed after a timeout (e.g., 30s)
  • Orphaned processes should be cleaned up on plugin shutdown

Suggested Fix

  1. Add a timeout to sendHeartbeat() (e.g., 30 seconds):

    const timeout = setTimeout(() => {
      child.kill(SIGTERM);
      resolve(); // Don't let a hung process block forever
    }, 30000);
  2. Track spawned child processes and kill them on shutdown

  3. Consider using spawn with stdio: ignore and detached: true so the child is independent and doesn't hold pipes open

  4. Fix the --project-folder argument to point to the actual project directory, not /

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions