Skip to content

Add strict parameter to cpu_bound and io_bound to propagate cancellation#5926

Open
calebgregory wants to merge 1 commit intozauberzeug:mainfrom
calebgregory:fix/run-strict-cancellation-propagation
Open

Add strict parameter to cpu_bound and io_bound to propagate cancellation#5926
calebgregory wants to merge 1 commit intozauberzeug:mainfrom
calebgregory:fix/run-strict-cancellation-propagation

Conversation

@calebgregory
Copy link
Copy Markdown

Closes #5925

Summary

_run silently returns None when the app is stopping, the executor is shut down, or the task is cancelled — violating the declared -> R return type. This causes two problems:

  1. Callers cannot distinguish a legitimate None return value from a cancellation
  2. Code that trusts the R return type hits downstream TypeErrors at runtime when R is non-optional

This PR adds a strict keyword parameter (default False) to cpu_bound and io_bound:

  • strict=True → raises run.CancelledError (subclass of asyncio.CancelledError) instead of returning None; return type is R
  • Default / strict=False → preserves current runtime behavior; return type is honestly R | None

Literal-based overloads let the type checker narrow the return type based on the value of strict.

Note: This is a breaking type-level change. The default return type is now R | None, so consumers will see new mypy errors where they previously did not. Runtime behavior is unchanged for callers that do not pass strict.

Test plan

  • Existing test_run.py tests pass (no behavioral regression)
  • New test_returns_none_when_app_is_stopping — verifies default behavior returns None (parametrized across cpu_bound and io_bound)
  • New test_strict_raises_when_app_is_stopping — verifies strict=True raises run.CancelledError (parametrized across cpu_bound and io_bound)
  • ruff check and autopep8 clean

Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com

…cellation (zauberzeug#5925)

`_run` silently returns `None` when the app is stopping, the executor is shut down,
or the task is cancelled — violating the declared `-> R` return type. This causes
two problems: (1) callers cannot distinguish a legitimate `None` return value from
a cancellation, and (2) code that trusts the `R` return type hits downstream
`TypeError`s at runtime when `R` is non-optional.

Add a `strict` keyword parameter (default `False`) to `cpu_bound` and `io_bound`.
When `True`, a `run.CancelledError` is raised instead of silently returning `None`.
`Literal`-based overloads let callers opt into a `-> R` return type by passing
`strict=True`.

Note: this is a breaking type-level change. The default return type is now honestly
`R | None`, so consumers will see new mypy errors where they previously did not.
Runtime behavior is unchanged for callers that do not pass `strict`.

Closes zauberzeug#5925
Comment on lines +23 to +29
class CancelledError(asyncio.CancelledError):
"""Raised by ``cpu_bound`` / ``io_bound`` when *strict=True* and the
background work is cancelled or the app is shutting down.

A subclass of :class:`asyncio.CancelledError` so that existing
``except CancelledError`` handlers continue to work.
"""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a strong reason to prefer a subclass vs just re-raising the existing exception... I suppose the one weird issue would be creating one in the first if strict pre-check.

But I'm generally hesitant to make new exceptions, and especially so when they have the same name as an underlying one. 🤔

Copy link
Copy Markdown
Author

@calebgregory calebgregory Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate the hesitancy to make new exceptions. I think that's why I had the impulse to name this one nicegui.run.CancelledError to match asyncio's name. But maybe it's better to give it a distinct name anyway, to prevent confusion.

I agree that raising a new asyncio.CancelledError() when the app is shutting down would be strange.

Defining the exception in nicegui.run offers some code-as-documentation + some consumer-convenience:

import asyncio
from nicegui import run

try:
    run.cpu_bound(..., strict=True)
except asyncio.CancelledError:
    ...

vs.

from nicegui import run

try:
    run.cpu_bound(..., strict=True)
except run.CancelledError:
    ...

Copy link
Copy Markdown
Contributor

@petergaultney petergaultney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks pretty good to me. I have the one question about the exception definition, but no very strong preference there.

@falkoschindler falkoschindler added bug Type/scope: Incorrect behavior in existing functionality review Status: PR is open and needs review labels Apr 3, 2026
@falkoschindler falkoschindler added this to the 3.11 milestone Apr 3, 2026
@falkoschindler falkoschindler self-requested a review April 3, 2026 13:15
Copy link
Copy Markdown
Contributor

@falkoschindler falkoschindler left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR, @calebgregory! The issue is real and the fix direction makes sense.

However, we're running into a mypy limitation with the current approach: you can't place additional keyword arguments between P.args and P.kwargs. That's why mypy reports "Arguments not allowed after ParamSpec.args" on every signature that mixes strict with ParamSpec.

We can work around this with overloads — using ParamSpec for the default (non-strict) overload and Callable[..., R] for the strict=True overload. That preserves argument type-checking for the default case. But strict=True loses ParamSpec checking, which is unfortunate since strict=True is meant to become the recommended mode (and eventually the default after the next major release).

Do you have ideas for how to keep ParamSpec argument checking in the strict=True case? Some options we've considered but aren't thrilled with:

  • Separate functions (e.g. cpu_bound_strict) instead of a parameter — preserves ParamSpec but clutters the API
  • A wrapper/decorator that converts the return type — might be too clever
  • Waiting for PEP 612 extensions that allow extra kwargs alongside ParamSpec — not available yet

Open to other ideas if you see a cleaner way to thread this needle.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Type/scope: Incorrect behavior in existing functionality review Status: PR is open and needs review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

run.cpu_bound / run.io_bound silently return None on cancellation instead of propagating CancelledError

3 participants