mpfaffenberger / mpfaffenberger/code_puppy
Ctrl+C fails to cancel agent during long tool calls (3 root causes, Windows + POSIX)
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:
- 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. - Expose a cancellable-in-flight registry (analogue of
_RUNNING_PROCESSES) that tools can opt into:register_cancel(handle, cancel_fn).schedule_agent_cancelsweeps it before doing
agent_task.cancel(). Plugins wrap their HTTP clients to honor a cancel
event. - 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
- 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. - Bug 3 (
stdin=DEVNULLon foreground shells) — one-line fix, cheap to
test. Windows-only but very high incidence for anyone usingtimeout,
pause, or a tool that reads stdin. - 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. - 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.pyrun_shell_command()line ~969: Bug 1, backgroundPopenmissing_register_process_run_command_sync()line ~1190: Bug 3, missingstdin=subprocess.DEVNULL_run_command_inner()line ~1229: Bug 2,run_in_executorarchitecture
code_puppy/agents/_run_signals.py—make_schedule_cancel,sigint_should_cancelcode_puppy/agents/_key_listeners.py— line 324 comment explaining the
WindowsENABLE_PROCESSED_INPUT/ raw-\x03path (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/urllibfor 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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