Monitor/TaskStop removes the task from the panel but leaves the spawned process running (orphaned tail -F accumulates)

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 4 comments · opened Sep 17, 2026

Summary

When a Monitor task ends — either by hitting its timeout_ms or via TaskStop — the entry disappears from the tasks panel, but the process it spawned keeps running. Re-arming the same monitor every 30 minutes therefore accumulates one orphaned process per cycle, indefinitely.

Same behaviour for background Bash tasks: TaskStop removes the panel entry without terminating the child process.

Environment

  • Claude Code 2.1.220, desktop app (Code tab)
  • Windows 10.0.19045, Git Bash (Bash tool)
  • Long-running session using Monitor to watch a log file

Reproduce

  1. Start a background process that writes to a log (e.g. Bash with run_in_background: true).
  2. Arm a watcher:

Monitor → command: tail -n0 -F /path/to.log | grep --line-buffered -E '^\*\*\*', timeout_ms: 1800000

  1. Wait for the 30-minute timeout (or call TaskStop).
  2. The task disappears from the panel. Now check the process table:

ps -ef | grep 'tail -n0 -F'

  1. The tail is still running. Re-arm the monitor and repeat — each cycle leaves another one.

Actual vs expected

  • Actual: panel entry removed, child process alive and holding the file open. Nothing in the UI indicates it still exists.
  • Expected: on timeout or TaskStop, the spawned process (ideally the whole process group) is terminated.

Measured impact

In a session that re-arms a log watcher every 30 minutes:

  • 10 live tail -n0 -F <same log> processes while exactly one Monitor was active (observed 2026-09-17 08:47).
  • Later the same day, 8 more had accumulated after the session that created them had ended; all 8 confirmed dead only after an explicit kill + verification.
  • Killed processes were verified with kill -0 afterwards, because a plain "no output from grep" is not proof of death.

Every leaked tail -F keeps a handle on a log file that the session no longer reads, and on Windows they re-parent so they are easy to miss. Because the panel is empty, there is no signal that cleanup is needed — the only way to find them is to inspect the process table manually.

Workaround

A helper that lists watcher processes (ps -ef, not ps aux — under Git Bash ps aux has no COMMAND column, so ps aux | grep … silently returns nothing and looks clean) and prints ready kill lines, plus a rule that TaskStop is treated as part of the kill rather than the cleanup itself:

TaskStop <task_id>   AND   kill <PID>     # both, in the same step

Note

This is easy to mistake for correct behaviour, because the missing panel entry reads as "the task is gone". It only becomes visible when something else counts the processes.

View original on GitHub ↗

3 Comments

LilPoppy · 25 days ago

Same symptom on Windows, with a reproducible mechanism that none of the related reports names. Tested on Claude Code 2.1.274 (the field case below happened on 2.1.272), Windows 11 26200.9457, Git for Windows bash.

Mechanism

Git Bash is MSYS2. When bash runs an MSYS program (a shell script such as npx, or tail/grep/cut), the exec emulation creates a new Windows process and the old one exits. The new process's recorded parent PID is dead from the moment it starts, so the Windows parent/child chain is broken at that hop.

TaskStop (and background-task cleanup generally) appears to terminate along the Windows parent chain starting from the shell it spawned. Everything below the break survives, and the task is still reported as Successfully stopped.

Below the break the chain is intact: a single taskkill /T /F on the surviving root kills the whole subtree. That also rules out a job object being used — job membership is inherited at creation and would have caught these processes regardless of the broken parent link.

Minimal repro

Each command was started with run_in_background: true, then stopped with TaskStop:

| Command | Chain before stop | After stop |
|---|---|---|
| node -e "setInterval(()=>{},1000)" 2>&1 \| tail -40 | intact | everything killed |
| ./fake-npx wrangler dev 2>&1 \| tail -40 where fake-npx is a bash script that runs node → cmd /d /s /c node … → node → 2 child node | broken at the bash script | 9 processes survived |
| same tree, entry point changed to fake-npx.cmd (a batch file) | intact | everything killed |

# fake-npx  (bash script, mimics /c/Program Files/nodejs/npx)
#!/usr/bin/env bash
basedir=$(dirname "$(echo "$0" | sed -e 's,\,/,g')")
"node" "$basedir/fake-npx-cli.js" "$@"
// fake-npx-cli.js  (mimics npm exec: runs the bin through cmd.exe)
const { spawn } = require('child_process');
const c = spawn('node fake-wrangler.js probe', { shell: true, stdio: 'inherit', cwd: __dirname });
c.on('exit', (code) => process.exit(code ?? 1));
// fake-wrangler.js  (mimics wrangler: respawns itself, then starts two long-lived helpers)
const { spawn } = require('child_process');
if (!process.argv.includes('--dist')) {
  const c = spawn(process.execPath, [__filename, 'dist', '--dist'], { stdio: 'inherit' });
  c.on('exit', (code) => process.exit(code ?? 1));
} else {
  for (const tag of ['esbuild', 'workerd']) spawn(process.execPath, ['-e', `/*${tag}*/ setInterval(()=>{},1000)`], { stdio: 'inherit' });
  setInterval(() => {}, 1000);
}

Note that npx on Windows resolves to a bash script under Git Bash, so npx <anything long-running> always lands on the broken path, while npx.cmd does not.

Field case

A subagent started npx wrangler dev --port 8799 --local 2>&1 | tail -40 four times with run_in_background: true and called TaskStop after each one. All four calls returned Successfully stopped. Three and a half days later all four trees were still running: 45 processes, ~3.4 GB private memory, ~6 CPU-hours, and the four esbuild watchers had issued roughly 1e9 file-metadata operations between them (~3,300/s combined, measured via OtherOperationCount). Separately, three tail -F | grep | cut monitor pipelines from another session survived the same way — tail, grep and cut are MSYS programs, so they break the chain by themselves.

Nothing else reaps these: Windows has no cascading process-group kill, wrangler dev never exits on its own, and tail waits forever for an EOF that the surviving writer never sends. The stale servers even released their listening port, so the next launch on the same port succeeded and the caller had no signal that anything had leaked.

Suggested fix

Assign each Bash-tool shell to a Windows job object created with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and without silent breakaway, then close the job on timeout/TaskStop/session exit. Job membership is inherited by every descendant no matter what happens to parent PIDs, so it is immune to the MSYS exec break. Verify the descendants are gone before reporting Successfully stopped.

Pioszym · 24 days ago

Measured follow-up to @LilPoppy's analysis, which matches what I see — with one addition that narrows the viable fix.

**In an MSYS-only tree the chain is broken at every hop, not just the first.**

My leaked processes are tail, grep and sleep — all MSYS programs. I built the tree bash script.sh → tail -n0 -F … | grep --line-buffered … + two sleep, then enumerated descendants the Windows way (Win32_Process.ParentProcessId) starting from the root's Windows PID:

  • descendants found: none
  • job object assigned the root only; on close, the root died and all four children survived
  • the tail process's Windows ParentProcessId pointed at a PID that no longer existed

So for these trees there is nothing below the break to walk: every hop re-parents. taskkill /T /F on the surviving root is blind to them for the same reason. That still holds in the node/npx case from the comment above, where the native part of the tree is intact — but it means a fix must not rely on walking the tree at stop time.

Consequence for the suggested fix: the job object has to be created and assigned when the shell is spawned, not derived at TaskStop/timeout. Job membership is inherited across the MSYS exec break, so it is the only mechanism here that survives it; anything that enumerates descendants first will miss exactly the processes that leak.

Workaround that does work today, for anyone cleaning up manually: enumerate the tree with MSYS ps -ef (its PPID column is intact and knows the real parentage), map each PID to its Windows PID via the WINPID column of plain ps, assign all of them to one job object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, then close the handle and verify. Tested: root + tail + grep + 2× sleep → 5/5 gone from a single root PID.

Minor detail for the tail -F | grep case specifically: killing the tail alone is enough, because grep then gets EOF on the pipe and exits — measured. So a re-armed log watcher leaks one process per cycle, not two. Processes that EOF does not reach (sleep, an inline loop, node) are the ones that accumulate.

Environment: Claude Code 2.1.220, Windows 10.0.19045, Git for Windows bash.

crui3er · 22 days ago

Still present on 2.1.278 (newest version named in this thread so far), Windows 11 26200.9457, Git for Windows bash. Adding handle-level forensics that corroborate @Pioszym's "chain broken at every hop" measurement from a different angle, plus one escape hatch that turns out to be closed too.

Case: 10 orphans (5 × tail -f <file> | grep -E --line-buffered <pat>) alive for ~34 h at 0 % CPU delta over a 3 s sample, ~51 MB combined working set. All 10 had dead ParentProcessIds.

1. Re-parenting signature. The two halves of a single pipeline, spawned in the same second by the same subshell, have different dead PPIDs — e.g. tail 18012 → PPID 10788, grep 29540 → PPID 38720. Sibling-level divergence, consistent with the MSYS exec break re-parenting each hop independently.

2. Why the pair cannot self-terminate. A system-wide handle search shows both halves on the same MSYS pipe:

tail.exe   pid: 18012  5CC: \Device\NamedPipe\1888ae32e00d56aa-49308-pipe-nt-0x1
grep.exe   pid: 29540  1B4: \Device\NamedPipe\1888ae32e00d56aa-49308-pipe-nt-0x1

grep blocks on read() and will only get EOF when the last write handle closes — held by tail, which never exits by design. This is the same fact as "killing tail alone is enough", measured from the handle side.

3. The EPIPE escape is closed as well. Their stdout/stderr are libuv pipes owned by the still-running session process:

tail 18012  -> uv\2-25708
grep 29540  -> uv\2-25708, uv\1-25708     (25708 = claude.exe, alive)

So no broken-pipe teardown either. And even if that process exited, neither orphan would notice: both are blocked on read/sleep and never attempt another write — tail's target file stopped growing 34 h ago. Worth noting the spawning session never ended and still considers these tasks finished, so the leak happens at task end, not only at session end (cf. #92583).

4. On taskkill /T /F. In a sandbox repro where the PPID chain stayed intact (bash → bash → tail/grep), taskkill /PID <root> /T /F did clean all four. On the real orphans it would find nothing, for exactly the reason measured above. Supports the conclusion that the job object has to be assigned at spawn time rather than derived at stop time.

Command-level workaround that needs no harness change, for anyone hitting this with log watchers: GNU tail in Git Bash supports --pid, so

tail -f --pid=$$ run.log | grep -E --line-buffered "pattern"

makes the pipeline self-terminating — tail dies with the shell, grep follows on EOF. Verified on Git for Windows.

Showing cached comments. Read the full discussion on GitHub ↗