Skip to content

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

@calebgregory

Description

@calebgregory

Description

run.cpu_bound and run.io_bound are typed as returning R, but the underlying _run function silently returns None in three cases instead of raising:

https://github.com/zauberzeug/nicegui/blob/62038937029e6a57a2db870f8b43a8c3a1f13fa0/nicegui/run.py#L57-L68

async def _run(executor: Any, callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
    if core.app.is_stopping:
        return  # type: ignore
    try:
        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(executor, partial(callback, *args, **kwargs))
    except RuntimeError as e:
        if 'cannot schedule new futures after shutdown' not in str(e):
            raise
    except asyncio.CancelledError:
        pass
    return  # type: ignore
  1. core.app.is_stopping is True
  2. RuntimeError with "cannot schedule new futures after shutdown"
  3. asyncio.CancelledError is caught

All three paths return bare return (i.e. None), which violates the declared -> R return type (acknowledged by the # type: ignore comments).

Problem

None is not a safe sentinel value for higher-order functions. The wrapped callback might legitimately return None, making it impossible for callers to distinguish "the function returned None" from "the task was cancelled/the app is shutting down." This causes downstream TypeErrors or AttributeErrors in callers that trust the return type.

We hit this in practice: a UI handler uses cpu_bound to run a constraint-checking function. When a previous asyncio.Task is cancelled (to debounce rapid UI changes), cpu_bound returns None instead of propagating CancelledError. The caller passes None into a function expecting a dict, causing a TypeError: 'NoneType' object is not iterable.

Proposed fix

Add a parameter to cpu_bound / io_bound that controls whether CancelledError is propagated:

async def cpu_bound(callback: Callable[P, R], *args: P.args, propagate_cancellation: bool = False, **kwargs: P.kwargs) -> R:

When propagate_cancellation=True, re-raise CancelledError instead of swallowing it. The default of False preserves backward compatibility.

This would let callers opt in to correct cancellation semantics while not breaking existing code that depends on the current (silent) behavior.

Alternative: type-safe overload with strict parameter

The above proposal addresses the CancelledError case but does not fix the is_stopping or RuntimeError paths, which still silently return None. A more complete fix could use @overload to make the None possibility visible in the type system:

@overload
async def cpu_bound(callback: Callable[P, R], *args: P.args, strict: Literal[True], **kwargs: P.kwargs) -> R: ...
@overload
async def cpu_bound(callback: Callable[P, R], *args: P.args, strict: Literal[False] = ..., **kwargs: P.kwargs) -> R | None: ...
  • strict=True: raises in all three None-return paths (CancelledError is re-raised; a new exception like AppShuttingDownError is raised for the other two). Return type is R.
  • strict=False (default): current behavior, but return type is honestly R | None.

This gives callers a migration path to fully type-safe behavior without breaking existing code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugType/scope: Incorrect behavior in existing functionalityreviewStatus: PR is open and needs review

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions