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
core.app.is_stopping is True
RuntimeError with "cannot schedule new futures after shutdown"
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.
Description
run.cpu_boundandrun.io_boundare typed as returningR, but the underlying_runfunction silently returnsNonein three cases instead of raising:https://github.com/zauberzeug/nicegui/blob/62038937029e6a57a2db870f8b43a8c3a1f13fa0/nicegui/run.py#L57-L68
core.app.is_stoppingisTrueRuntimeErrorwith "cannot schedule new futures after shutdown"asyncio.CancelledErroris caughtAll three paths return bare
return(i.e.None), which violates the declared-> Rreturn type (acknowledged by the# type: ignorecomments).Problem
Noneis not a safe sentinel value for higher-order functions. The wrapped callback might legitimately returnNone, making it impossible for callers to distinguish "the function returned None" from "the task was cancelled/the app is shutting down." This causes downstreamTypeErrors orAttributeErrors in callers that trust the return type.We hit this in practice: a UI handler uses
cpu_boundto run a constraint-checking function. When a previousasyncio.Taskis cancelled (to debounce rapid UI changes),cpu_boundreturnsNoneinstead of propagatingCancelledError. The caller passesNoneinto a function expecting adict, causing aTypeError: 'NoneType' object is not iterable.Proposed fix
Add a parameter to
cpu_bound/io_boundthat controls whetherCancelledErroris propagated:When
propagate_cancellation=True, re-raiseCancelledErrorinstead of swallowing it. The default ofFalsepreserves 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
strictparameterThe above proposal addresses the
CancelledErrorcase but does not fix theis_stoppingorRuntimeErrorpaths, which still silently returnNone. A more complete fix could use@overloadto make theNonepossibility visible in the type system:strict=True: raises in all threeNone-return paths (CancelledErroris re-raised; a new exception likeAppShuttingDownErroris raised for the other two). Return type isR.strict=False(default): current behavior, but return type is honestlyR | None.This gives callers a migration path to fully type-safe behavior without breaking existing code.