mpfaffenberger / mpfaffenberger/code_puppy

Ctrl+C fails to cancel agent during long tool calls (3 root causes, Windows + POSIX)

Open
#671 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
814
Forks
278
Avg merge
2d 5h
Merged PRs (30d)
76

Description

Bug — Ctrl+C fails to cancel agent when a long tool call is running

Code Puppy version: 0.1.46 (from PyPI, unmodified)
OS observed on: Windows 11 (nt)
Also affects: POSIX for Bug 1 and Bug 2 (Bug 3 is Windows-only)
Symptom: Pressed Ctrl+C during a long-running tool call. Nothing visible
happened. Cancel banner never appeared, background jobs kept running, the run
only "released" when the tool call finished on its own.


TL;DR

Three separate defects can make Ctrl+C look dead. All are in stock Code Puppy
0.1.46, in unmodified core files.

# Bug Layer Repros with
1 Background shell processes are never registered in _RUNNING_PROCESSES, so cancel can't reach them Core command_runner.py Any OS, any agent_run_shell_command(background=True)
2 Blocking tool calls (non-shell) can't be pre-empted — cancel scheduled but coroutine keeps blocking until the underlying thread returns naturally Core architectural pattern (run_in_executor + _SHELL_EXECUTOR) Any OS, any long blocking tool (read_file on big file, browser tools, HTTP-based plugins)
3 Foreground shells inherit stdin, so on Windows the raw \x03 byte can be swallowed by the child before the parent's key listener reads it Core command_runner.py Windows only, any child that touches console input (timeout /t N /nobreak, pause, interactive prompts)

Verification that these are upstream, not a fork/customization

Both bug-carrying files match the shipped wheel's SHA256 exactly — the install
is unmodified stock Code Puppy 0.1.46 from PyPI:

File SHA256 (start) Wheel RECORD expected (urlsafe-b64) Match
code_puppy/tools/command_runner.py F47DAF51FC38F99D... 9H2vUfw4-Z1KeI3H... (decodes to F47DAF51FC38F99D...) Yes
code_puppy/agents/_run_signals.py 7C4DDFE321CC03AD... fE3f4yHMA60nEyiP... (decodes to 7C4DDFE321CC03AD...) Yes

Reproducible from any clean install of code-puppy==0.1.46 on PyPI. No
plugins, agents, or config changes required.


Bug 1 — Background shells aren't tracked, so cancel can't kill them

Evidence

code_puppy/tools/command_runner.py, run_shell_command(), the if background: branch (lines ~969–1032):

if background:
    log_file = tempfile.NamedTemporaryFile(...)
    ...
    if sys.platform.startswith("win"):
        creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
        process = subprocess.Popen(
            command, shell=True,
            stdout=log_file, stderr=subprocess.STDOUT,
            stdin=subprocess.DEVNULL,
            cwd=cwd, creationflags=creationflags,
        )
    else:
        process = subprocess.Popen(..., start_new_session=True)

    log_file.close()
    ...
    # Return immediately - don't wait, don't block
    return ShellCommandOutput(..., background=True, pid=process.pid)

_register_process(process) is never called. Compare to the foreground
path (_run_command_sync, line ~1218): _register_process(process) runs right
after the Popen.

Why that breaks cancel

code_puppy/agents/_run_signals.py, make_schedule_cancel:

def schedule_agent_cancel(force: bool = False) -> None:
    ...
    if _RUNNING_PROCESSES and not force:
        _tear_down_live_panels()
        emit_warning("Cancel requested! Stopping the agent (shells + all sub-agents)...")
        kill_all_running_shell_processes()
    ...
    loop.call_soon_threadsafe(agent_task.cancel)

Because _RUNNING_PROCESSES is empty (background procs were never added), this
branch is skipped — no cancel banner, no taskkill /F /T on the background
PIDs. The agent task IS cancelled, but the background subprocesses (detached
via CREATE_NEW_PROCESS_GROUP on Windows or start_new_session=True on POSIX,
with a closed log file handle on both) live on until they finish their work.

Proposed fix
# right after Popen in the background branch:
_register_process(process)

There's a design question about whether background processes should be killed
on cancel at all (they're deliberately detached and outlive the run), but at
minimum they should be discoverable and killable via the cancel path. An
opt-in flag to kill_all_running_shell_processes() for background procs would
resolve the design tension:

def kill_all_running_shell_processes(include_background: bool = True) -> int:
    ...

Bug 2 — Cancel can't stop blocking, non-shell tool calls

The mechanism

Shell tool calls run through _run_command_inner
(command_runner.py line ~1229):

async def _run_command_inner(...):
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(
        _SHELL_EXECUTOR,
        partial(_run_command_sync, command, cwd, timeout, group_id, silent),
    )

_SHELL_EXECUTOR is a ThreadPoolExecutor(max_workers=16).
schedule_agent_cancel calls agent_task.cancel(). In asyncio, cancelling a
task raises CancelledError at the next await boundary — but a
ThreadPoolExecutor worker thread cannot be pre-empted from Python.
The
worker keeps running _run_command_sync until the subprocess finishes.

Code Puppy's mitigation for shells is that schedule_agent_cancel calls
kill_all_running_shell_processes() FIRST (which sends taskkill /F /T /
SIGKILL to every tracked subprocess), which unblocks process.wait() inside
the worker, which returns, which lets the executor future resolve, which
propagates the cancel to the awaiting coroutine.

This mitigation is shell-specific. Any other blocking tool has the same
underlying pattern (blocking Python code dispatched to a thread), but no
kill-path to unblock it. Concrete examples that ship with stock Code Puppy:

Tool What blocks Cancel behavior
read_file on a very large file open().read() Cancel queued, read runs to completion, THEN the agent stops
list_files on a huge tree Filesystem walk in a thread Same
grep on a large tree ripgrep subprocess (this one IS tracked, so it cancels fine) OK
Any browser_* action stuck on a slow page Playwright blocking call Same freeze pattern
Any plugin tool doing requests.get() / httpx.post() / urllib Blocking network I/O Same

User-visible symptom for Bug 2: press Ctrl+C during a slow tool, no banner
appears, terminal looks frozen, then N seconds/minutes later the run "suddenly"
cancels. Feels like Ctrl+C did nothing.

Proposed fix

Three options, ranked:

  1. Immediate UI feedback on Ctrl+C — the cheapest partial fix. The moment
    the key listener fires the cancel handler, emit a "Cancel requested,
    waiting for tool X to return..." banner UNCONDITIONALLY, before the
    if _RUNNING_PROCESSES: check. Right now the banner is inside that branch,
    so cancels that can't kill anything are silent.
  2. Expose a cancellable-in-flight registry (analogue of
    _RUNNING_PROCESSES) that tools can opt into: register_cancel(handle, cancel_fn). schedule_agent_cancel sweeps it before doing
    agent_task.cancel(). Plugins wrap their HTTP clients to honor a cancel
    event.
  3. Documented convention for plugin authors that any blocking work must
    poll a shared cancel event. Cheapest to ship, worst latency for long HTTP
    calls, but at least gives plugin authors a documented pattern.

Bug 3 (Windows) — Foreground shells inherit stdin; \x03 may reach the child instead of the parent's key listener

Evidence — asymmetric stdin handling

Background branch (command_runner.py ~987) explicitly detaches stdin:

subprocess.Popen(
    command, shell=True,
    stdout=log_file, stderr=subprocess.STDOUT,
    stdin=subprocess.DEVNULL,       # <-- explicitly detached
    cwd=cwd, creationflags=CREATE_NEW_PROCESS_GROUP,
)

Foreground branch _run_command_sync (~1198) does NOT:

process = subprocess.Popen(
    command, shell=True,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    cwd=cwd,
    bufsize=0,
    preexec_fn=preexec_fn,
    creationflags=creationflags,    # CREATE_NEW_PROCESS_GROUP on Windows
    # stdin is NOT set -> child inherits the parent's stdin (the console)
)
Why this matters on Windows

code_puppy/agents/_key_listeners.py line 324 explicitly documents:

Raw Ctrl+C byte. On Windows the session strips ENABLE_PROCESSED_INPUT,
so ^C reaches the listener as this byte instead of becoming a SIGINT.

So on Windows, cancel-during-shell relies on the parent's key-listener thread
reading \x03 from msvcrt.getwch(). But the child cmd.exe inherited the same
console input handle. When the child (or a grandchild like timeout.exe) reads
from stdin — or when the console driver races the two consumers — the byte can
be delivered to the child. timeout /t N /nobreak explicitly ignores Ctrl+C so
it swallows the character without acting on it. Result: the parent's key
listener never sees the byte, cancel handler never fires.

Proposed fix

Add stdin=subprocess.DEVNULL to the foreground subprocess.Popen call,
matching the background branch. Foreground shells that need stdin (interactive
prompts) are already impossible with the current design (stdout is piped away
from the terminal), so this shouldn't regress anything.


Minimal reproductions (all use stock tools only, no plugins required)

Bug 1 (any OS)
agent_run_shell_command(
    background=True,
    command="python -c \"import time; [print(i) or time.sleep(1) for i in range(120)]\""
)

Then send another prompt to the agent and press Ctrl+C during any subsequent
tool call. The background python keeps running in the background (check via
Get-Process python or ps aux | grep python), and no banner mentions it.

Bug 3 (Windows only)
agent_run_shell_command(
    command="timeout /t 60 /nobreak >nul && echo done",
    timeout=90
)

Press Ctrl+C during the 60s wait. Nothing happens until timeout completes
naturally.

Bug 2 (any OS, no plugin needed)
create_file(
    file_path="big.txt",
    content="x\n" * 50_000_000  # about 100 MB
)
read_file(file_path="big.txt")

Press Ctrl+C during the read. Nothing visible happens — the read has to
complete on its own before cancel takes effect.


Suggested triage priority

  1. Bug 1 (background not registered) — one-line fix
    (_register_process(process)), no downside. High incidence: any user who
    spawns a background job and later wants to cancel.
  2. Bug 3 (stdin=DEVNULL on foreground shells) — one-line fix, cheap to
    test. Windows-only but very high incidence for anyone using timeout,
    pause, or a tool that reads stdin.
  3. UI feedback part of Bug 2 — emit "Cancel requested, waiting for tool X
    to unblock..." unconditionally on Ctrl+C, so the UI feels responsive even
    when the underlying work can't be interrupted. Small change, big UX win.
  4. Full Bug 2 (cancellable in-flight registry) — larger design change,
    worth doing but not urgent once the UI feedback is in place.

Repo pointers for the fixer

  • code_puppy/tools/command_runner.py
    • run_shell_command() line ~969: Bug 1, background Popen missing _register_process
    • _run_command_sync() line ~1190: Bug 3, missing stdin=subprocess.DEVNULL
    • _run_command_inner() line ~1229: Bug 2, run_in_executor architecture
  • code_puppy/agents/_run_signals.pymake_schedule_cancel, sigint_should_cancel
  • code_puppy/agents/_key_listeners.py — line 324 comment explaining the
    Windows ENABLE_PROCESSED_INPUT / raw-\x03 path (relevant context for
    Bug 3)
  • code_puppy/agents/_runtime.py — SIGINT handler installation around lines
    758–820

Impact / who is affected

  • Bug 1 — every user, every OS, whenever a background job outlives the
    moment the user wants to cancel. Silent — user thinks the process died with
    the agent when it did not.
  • Bug 2 — every user, every OS, any time a plugin/tool does long blocking
    work. Very visible on any HTTP-based plugin (anything using
    requests/httpx/urllib for network calls) and on the stock
    read_file/list_files/browser_* tools when they hit large inputs or
    slow pages.
  • Bug 3 — Windows users only, but affected any time a child process
    interacts with the console (timeout /nobreak, pause, read,
    interactive Python REPLs, etc.).

All three are reproducible on a clean install of code-puppy==0.1.46 from
PyPI. No third-party plugins, custom agents, or config changes required — the
repros above use only the built-in agent_run_shell_command, create_file,
and read_file tools.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the cited repros and read run_shell_command() and _run_command_sync() in code_puppy/tools/command_runner.py, then trace cancellation through make_schedule_cancel in code_puppy/agents/_run_signals.py. Review _key_listeners.py and _runtime.py for the Windows Ctrl+C path. Done means the agreed cancellation behavior is implemented for the selected root causes and the supplied background, foreground-shell, and blocking-tool scenarios behave as documented.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend, cli
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.