Asyncio Cancellation and Timeouts on Low-RAM Targets

Within the Local Spatial Processing Patterns guide, this page zooms in on a single failure mode of the scheduling discipline set out in async execution for spatial workloads: what happens the instant a coroutine has to be abandoned mid-flight rather than allowed to run to completion. A gateway that dispatches CRS transforms and topology checks against a hard deadline will, over enough hours of uptime, hit every one of its timeouts. If cancellation is treated as an afterthought, each of those timeouts leaks a little — a dangling mmap, a ctypes handle that never got released, a task object that outlives its caller — and a device with 256 MB to 2 GB of RAM runs out of headroom long before it runs out of uptime. Correct cancellation is not exception handling bolted onto the happy path; it is the mechanism that keeps a long-running edge service’s memory footprint flat.

The deployment context matches the parent guide: Python 3.11+ on a Linux gateway, an asyncio event loop draining sensor input, and CPU-bound geometry work farmed out to workers or native libraries through FFI. What changes here is the question. Instead of “how do we dispatch work without blocking the loop,” it is “how do we guarantee that work we no longer want still releases everything it was holding.” Getting that guarantee wrong is invisible in a two-hour test run and fatal after a week in the field, which is exactly why it deserves its own treatment separate from device constraints and resource limits.

Cancellation model selection rationale

Python’s cancellation model is cooperative: task.cancel() does not stop a coroutine, it schedules a CancelledError to be raised at the coroutine’s next suspension point — typically the next await. Between cancellation requests and that raise, arbitrary code keeps running. This has two direct consequences for a memory-constrained gateway. First, a coroutine that never awaits inside a long CPU-bound stretch is effectively uncancellable until it chooses to yield, so cancellation only works if the code was written to cooperate with it. Second, and more subtly, CancelledError can arrive at any await, including ones sitting in the middle of a resource-acquisition sequence — which is why cleanup has to be written as if the interrupt could land between any two lines, not just at the end.

Three primitives cover the entire problem, and each maps to a specific constraint:

  1. Deadlines, not indefinite awaits. asyncio.wait_for() or, from Python 3.11 onward, the asyncio.timeout() context manager wraps an awaitable with a hard ceiling. An unbounded await on a stalled FFI call or a wedged socket read is the single most common cause of an edge process quietly accumulating buffers until the OOM killer intervenes — nothing ever returns, so nothing ever gets released.
  2. Guaranteed cleanup, not best-effort cleanup. A try/finally around any native resource acquisition — an mmap, a GEOSContext, a file descriptor — runs on every exit path, including the CancelledError path. Cancellation must not be treated as a special case; it is just another way the try block can end.
  3. Selective immunity, not blanket immunity. asyncio.shield() protects a specific inner awaitable from a caller’s cancellation without protecting the outer coroutine itself, which is the difference between “let this one commit finish flushing to flash” and “ignore cancellation forever.”

asyncio.timeout() is a Python 3.11 addition and the version this page assumes; on 3.10 and earlier, asyncio.wait_for() is the equivalent and is used interchangeably below where the target still runs an older interpreter.

Lifecycle of a cancelled coroutine from deadline to released resources A task starts and enters an asyncio timeout scope, which begins awaiting a shielded FFI call. When the deadline expires a CancelledError is raised at the next await inside the scope, which triggers the finally block that releases the mmap and FFI handle, after which the task reports itself as cancelled with zero leaked resources. Task starts enters timeout scope Shielded FFI await holds mmap + handle Deadline expires CancelledError raised finally block runs closes mmap + FFI Task cancelled zero leaked handles

A cancelled task only counts as safe once the finally block has run and every native handle it held has been released.

The complete implementation

The pattern below wraps a single FFI-backed geometry lookup — an mmap-ed grid lookup plus a native contains check — with a deadline, a shielded critical section for the part that must not be interrupted half-written, and unconditional cleanup. It targets Python 3.11’s asyncio.timeout(); the commented alternative shows the wait_for() form for 3.9/3.10 targets still in the field.

# cancel_safe_lookup.py — deadline-bounded FFI lookup with guaranteed cleanup.
#
# Python 3.11+. On 3.9/3.10, replace the `async with asyncio.timeout(DEADLINE):`
# block with: await asyncio.wait_for(run_lookup(event), timeout=DEADLINE)

import asyncio
import mmap
import logging

DEADLINE_S = 0.75          # per-event deadline; well under the caller's SLA
FLUSH_GRACE_S = 0.2        # extra time granted only to the shielded commit

log = logging.getLogger("cancel_safe_lookup")

class GridHandle:
    """Owns one mmap'd static index and one native FFI context. Both must be
    released exactly once, on every exit path, including cancellation."""

    def __init__(self, path: str):
        self._fh = open(path, "rb")
        self._mm = mmap.mmap(self._fh.fileno(), 0, access=mmap.ACCESS_READ)
        self._ctx = _open_native_context(self._mm)   # ctypes/cffi handle

    def close(self):
        # Idempotent: safe to call twice if cleanup runs on more than one path.
        if self._ctx is not None:
            _close_native_context(self._ctx)
            self._ctx = None
        if self._mm is not None:
            self._mm.close()
            self._mm = None
        self._fh.close()


async def run_lookup(event: dict, grid: GridHandle) -> dict | None:
    """The cancellable part: probe the grid, then run the native predicate.
    Wrapped in try/finally so a CancelledError raised at either await still
    releases everything the coroutine was holding at the moment it fired."""
    try:
        candidates = await asyncio.to_thread(grid_probe, grid, event["lon"], event["lat"])
        if not candidates:
            return None
        # The native predicate call itself is the part that must not be torn
        # off half-way: shield it so an outer cancellation waits for THIS
        # await to return before the CancelledError is delivered here.
        match = await asyncio.shield(
            asyncio.to_thread(native_contains, grid, candidates, event)
        )
        return match
    except asyncio.CancelledError:
        log.warning("lookup cancelled for event %s; releasing grid handle state", event["id"])
        raise   # re-raise: swallowing CancelledError breaks the task's own cancellation
    finally:
        # Runs on success, on exception, and on cancellation alike.
        # No resource acquired above is allowed to survive past this point
        # without being explicitly reclaimed here or owned by the caller.
        pass  # grid is long-lived and owned by the caller; per-call state, if any, frees here


async def handle_event(event: dict, grid: GridHandle) -> dict | None:
    """Caller-facing entry point: enforces the hard per-event deadline."""
    try:
        async with asyncio.timeout(DEADLINE_S):
            return await run_lookup(event, grid)
    except TimeoutError:
        # asyncio.timeout() raises the stdlib TimeoutError, not asyncio.TimeoutError,
        # once the deadline is exceeded — asyncio.CancelledError fires internally first.
        log.warning("event %s exceeded %.2fs deadline; dropped", event["id"], DEADLINE_S)
        return None

The asyncio.shield() call is doing narrow, deliberate work: it stops an outer cancellation (the deadline in handle_event) from reaching into the native predicate call before that call has returned, while still letting the shielded awaitable itself be cancelled if something cancels it directly. Shielding the wrong scope — for example wrapping the entire run_lookup body instead of just the native call — quietly defeats the deadline altogether, because everything inside a shield keeps running to completion regardless of the timeout that wraps it.

Constraint validation table

Constraint Expected impact Mitigation built into the code
RAM An await with no deadline on a stalled FFI call accumulates queued events behind it until the process is OOM-killed Every dispatch is wrapped in asyncio.timeout(); nothing can await indefinitely
CPU Uncooperative cancellation leaves zombie coroutines still holding the event loop’s attention, wasting scheduling cycles try/finally guarantees native handles are released the moment cancellation lands, not on a delayed GC pass
Latency A caller waiting on handle_event with no deadline inherits whatever latency the native call has, unbounded DEADLINE_S bounds worst-case latency per event; shielded work adds at most FLUSH_GRACE_S beyond it
Power Leaked mmap regions and file descriptors keep pages resident and force extra page-cache churn, costing cycles and battery Idempotent GridHandle.close() guarantees the mapping is torn down exactly once, never left dangling

Gotchas and edge cases

  • Swallowing CancelledError silently breaks cancellation for everyone. Catching it to log a message is fine; catching it and not re-raising tells the event loop the task finished normally, which can leave a caller waiting on a wait_for() that never times out because the inner task claimed success. Always raise after handling.
  • asyncio.timeout() raises TimeoutError, not asyncio.TimeoutError. As of Python 3.11 the two names are the same object, but code written against wait_for() on older interpreters that catches asyncio.TimeoutError explicitly should confirm both names resolve identically on the target’s interpreter before relying on it.
  • Shielding the wrong scope defeats the deadline. asyncio.shield() protects only the awaitable passed to it, not the coroutine that calls it. Shield the smallest possible critical section — usually a single commit or flush — never the whole handler, or the deadline becomes decorative.
  • finally blocks can themselves be cancelled. A second cancellation delivered while a finally block is already unwinding (for example from a caller that gives up and cancels again) can interrupt cleanup mid-way. Keep cleanup code synchronous and allocation-free where possible so there is nothing left for a second cancellation to interrupt.
  • RTOS and single-core targets exaggerate the cost of uncooperative code. A tight CPU-bound loop with no await blocks the entire loop on a single-core gateway exactly as described in the parent guide’s GIL discussion; cancellation cannot help a coroutine that never yields.
  • GPS-driven event bursts outrun a fixed deadline. During a high-rate GNSS burst, DEADLINE_S may need to shrink dynamically alongside the chunk-size adjustments described for thermal throttling in the parent guide, or the drop rate under load will climb faster than expected.

Integrating with the ingestion loop

handle_event slots directly into the queue-draining loop from the parent guide’s dispatch pattern: each drained event gets its own asyncio.timeout() scope rather than sharing one deadline across a whole chunk, so a single slow lookup cannot starve its neighbors. Track in-flight tasks with a plain set() so a shutdown signal can cancel and await them cleanly instead of abandoning the process mid-write:

in_flight: set[asyncio.Task] = set()

def dispatch(event: dict, grid: GridHandle):
    task = asyncio.create_task(handle_event(event, grid))
    in_flight.add(task)
    task.add_done_callback(in_flight.discard)  # prevents the set from growing without bound

async def shutdown():
    for task in list(in_flight):
        task.cancel()
    # gather with return_exceptions=True so one CancelledError doesn't hide the rest
    await asyncio.gather(*in_flight, return_exceptions=True)

The add_done_callback(in_flight.discard) line matters as much as the timeout itself: without it, in_flight grows for the life of the process, and the set of task objects becomes its own slow leak on a device with no memory to spare. On shutdown or a watchdog-triggered restart, cancel every tracked task and gather them before exiting so every finally block gets a chance to run before the process disappears.