Claude Code Plugins

Community-maintained marketplace

Feedback

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.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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:

# 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)