---
name: cancel-async-tasks
description: Guidance for implementing proper asyncio task cancellation with signal handling in Python. This skill applies when implementing concurrent task runners that need graceful shutdown, handling KeyboardInterrupt/SIGINT in asyncio contexts, or managing task cleanup when using semaphores for concurrency limiting. Use when tasks involve asyncio.gather, CancelledError handling, or cleanup of tasks that haven't started execution.
---

# Cancel Async Tasks

## Overview

This skill provides guidance for implementing robust asyncio task cancellation in Python, particularly when dealing with signal handling (SIGINT/KeyboardInterrupt), semaphore-based concurrency limiting, and ensuring proper cleanup of all tasks including those waiting in queues.

## Key Concepts

### Signal Propagation in Asyncio

Understanding how signals interact with asyncio is critical:

1. **KeyboardInterrupt vs CancelledError**: When SIGINT is received during `asyncio.run()`, the behavior differs from catching exceptions inside async code. The event loop typically converts the interrupt to `CancelledError` that propagates through tasks.

2. **Signal handler context**: Signal handlers run in the main thread, but asyncio tasks may be in various states (running, waiting on semaphore, waiting on I/O).

3. **Event loop state**: The event loop's handling of SIGINT depends on whether it's running `asyncio.run()` vs manual loop management.

### Task Lifecycle States

When cancellation occurs, tasks can be in different states:

1. **Running tasks**: Currently executing code
2. **Awaiting tasks**: Blocked on I/O or other coroutines
3. **Semaphore-waiting tasks**: Waiting to acquire a semaphore for concurrency limiting
4. **Not-yet-started tasks**: Created but not yet scheduled

Each state requires different handling for proper cleanup.

## Potential Approaches

### Approach 1: Task Group with Exception Handling

Use `asyncio.TaskGroup` (Python 3.11+) for automatic cancellation propagation:

- TaskGroup automatically cancels remaining tasks when one fails
- Provides structured concurrency guarantees
- Consider whether this matches the cleanup requirements

### Approach 2: Manual Task Tracking with Shield

Track all task objects explicitly and handle cancellation:

- Maintain a list of all created task objects
- Use `asyncio.shield()` for cleanup operations that must complete
- Implement explicit cancellation loop for all tracked tasks

### Approach 3: Signal Handler Registration

Register explicit signal handlers for SIGINT/SIGTERM:

- Use `loop.add_signal_handler()` to register custom handlers
- Set a cancellation flag or event that tasks check
- Coordinate shutdown through the event loop

### Approach 4: Context Manager Pattern

Wrap task execution in a context manager that handles cleanup:

- `__aenter__` sets up tasks and tracking
- `__aexit__` ensures all tasks are cancelled and awaited
- Handles exceptions uniformly

## Verification Strategies

### Testing with Real Signals

**Critical**: Test with actual signals, not timeouts:

```python
# Correct approach: Use subprocess with actual SIGINT
import subprocess
import signal
import time

proc = subprocess.Popen(['python', 'script.py'])
time.sleep(1)  # Let tasks start
proc.send_signal(signal.SIGINT)
stdout, stderr = proc.communicate(timeout=5)
# Verify cleanup messages in output
```

**Incorrect approach** (gives false confidence):
- Using `asyncio.wait_for()` with timeout does not replicate SIGINT behavior
- Using `asyncio.CancelledError` directly differs from signal-triggered cancellation

### Verification Checklist

1. **Running task cleanup**: Verify tasks actively executing receive cancellation
2. **Waiting task cleanup**: Verify tasks blocked on I/O are cancelled
3. **Semaphore queue cleanup**: Verify tasks waiting on semaphore acquisition are cancelled
4. **Cleanup code execution**: Verify finally blocks and cleanup handlers run
5. **No resource leaks**: Verify file handles, connections, etc. are closed
6. **Exit code verification**: Verify process exits with expected code after interrupt

### Test Scenarios to Cover

- Interrupt when all slots are filled (max_concurrent tasks running)
- Interrupt when tasks are queued waiting for semaphore
- Interrupt during cleanup phase itself
- Rapid repeated interrupts
- Interrupt before any task starts

## Common Pitfalls

### Pitfall 1: Catching KeyboardInterrupt Inside Async Functions

**Problem**: `KeyboardInterrupt` doesn't propagate normally through asyncio - it's typically converted to `CancelledError` by the event loop.

**Symptom**: Exception handlers for `KeyboardInterrupt` inside async functions never trigger during actual Ctrl+C.

**Solution**: Handle `CancelledError` instead, or register explicit signal handlers at the event loop level.

### Pitfall 2: asyncio.gather Doesn't Cancel Queued Tasks

**Problem**: When using `asyncio.gather` with more tasks than can run concurrently (via semaphore), cancelling gather doesn't automatically cancel tasks waiting to acquire the semaphore.

**Symptom**: Tasks that haven't started don't have their cleanup code run.

**Solution**: Explicitly track all task objects and cancel them individually, not just rely on gather's cancellation.

### Pitfall 3: Testing with Timeouts Instead of Signals

**Problem**: Using `asyncio.wait_for()` timeout to simulate interruption doesn't replicate actual signal handling behavior.

**Symptom**: Tests pass but actual Ctrl+C behavior differs.

**Solution**: Use `subprocess` with `signal.SIGINT` to test actual signal handling behavior.

### Pitfall 4: Cleanup During Cancellation

**Problem**: Cleanup code itself may be cancelled if not protected.

**Symptom**: Partial cleanup, resources not released.

**Solution**: Use `asyncio.shield()` for critical cleanup operations, or handle `CancelledError` and re-raise after cleanup.

### Pitfall 5: Duplicate Exception Handling Code

**Problem**: Identical cleanup code in multiple exception handlers (`CancelledError`, `KeyboardInterrupt`, etc.).

**Symptom**: Code duplication, maintenance burden.

**Solution**: Use a single handler with `except (asyncio.CancelledError, KeyboardInterrupt)` or abstract cleanup into a helper function.

### Pitfall 6: Not Awaiting Cancelled Tasks

**Problem**: Cancelling a task and not awaiting it leaves the task in a partially-cleaned-up state.

**Symptom**: Resource leaks, warnings about pending tasks.

**Solution**: Always `await asyncio.gather(*cancelled_tasks, return_exceptions=True)` after cancelling.

## Decision Framework

When implementing async task cancellation, consider:

1. **Python version**: TaskGroup (3.11+) vs manual management
2. **Concurrency model**: Fixed pool, semaphore-limited, or unlimited
3. **Cleanup requirements**: What must happen before exit?
4. **Signal handling needs**: Just SIGINT, or also SIGTERM, SIGHUP?
5. **Testing environment**: Can tests send real signals?

## Debugging Tips

- Add logging at task entry, exit, and cancellation points
- Log the task state when cancellation is received
- Use `asyncio.current_task()` to identify which task is executing
- Check `task.cancelled()` vs `task.done()` states
- Enable asyncio debug mode: `asyncio.run(main(), debug=True)`
