Skip to content

Fix emitted() timeout handling on Python 3.10 - #6197

Open
evnchn wants to merge 3 commits into
zauberzeug:mainfrom
evnchn:midnight/event-128
Open

Fix emitted() timeout handling on Python 3.10#6197
evnchn wants to merge 3 commits into
zauberzeug:mainfrom
evnchn:midnight/event-128

Conversation

@evnchn

@evnchn evnchn commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Motivation

On CPython 3.10, await some_event.emitted(timeout=1.0) on an event that never fires raises the bare asyncio.TimeoutError with no message instead of the intended TimeoutError('Timed out waiting for event after 1.0 seconds'), so a caller doing except TimeoutError as e: print(e) sees an empty/opaque message and no exception chaining.

Minimal reproduction (nicegui/event.py:128) — the exact test/script that was run to reproduce it:

"""MRE — NiceGUI `Event.emitted(timeout=...)` timeout wrapper is a no-op on Python 3.10.

Public API only: `from nicegui import Event` (exported in nicegui/__init__.py).

The bug lives in nicegui/event.py:126-129:

    try:
        return await asyncio.wait_for(future, timeout)
    except TimeoutError as error:                       # <-- builtin TimeoutError
        raise TimeoutError(f'Timed out waiting for event after {timeout} seconds') from error

On timeout, `asyncio.wait_for` raises `asyncio.TimeoutError`. On CPython 3.11+
`asyncio.TimeoutError` IS the builtin `TimeoutError`, so the `except` runs and the
caller gets the descriptive, chained `TimeoutError('Timed out waiting ... seconds')`.

On CPython 3.10 (and earlier) `asyncio.TimeoutError` is a SEPARATE class (an
`Exception` subclass in asyncio.exceptions), NOT the builtin `TimeoutError`
(an `OSError` subclass). So `except TimeoutError:` never catches it: the wrapper
never fires, and a bare `asyncio.TimeoutError` with an empty message and no
`__cause__` chaining escapes to the caller instead.

Observable symptom (Python 3.10 only): a caller writing the natural
`except TimeoutError as e: print(e)` never runs, and instead sees an opaque
bare asyncio.TimeoutError bubble out.

RUN:  /Users/evnchn/nicegui/.venv/bin/python mre.py
  - CPython 3.11+ (incl. this box, 3.12): prints PASS, exit 0.
  - CPython 3.10:                          prints BUG REPRODUCED, exit 1.
This box is 3.12, where the code is correct, so a run here PASSES and proves
nothing about the bug — reproduction requires a CPython 3.10 interpreter.
"""
import asyncio
import sys

from nicegui import Event


async def main() -> None:
    event = Event()  # never emitted, so emitted(timeout=...) must time out
    try:
        await event.emitted(timeout=0.1)
    except TimeoutError as error:  # exactly what a caller writes; builtin TimeoutError
        message = str(error)
        assert message == 'Timed out waiting for event after 0.1 seconds', \
            f'wrong message: {message!r}'
        assert error.__cause__ is not None, 'expected chained __cause__'
        print(f'PASS (CPython {sys.version_info.major}.{sys.version_info.minor}): '
              f'builtin `except TimeoutError` caught it -> {message!r}')
    except asyncio.TimeoutError as error:  # a DISTINCT class on CPython <3.11
        print('BUG REPRODUCED: emitted(timeout=) leaked a bare asyncio.TimeoutError '
              f'(message={str(error)!r}, cause={error.__cause__!r}); builtin '
              '`except TimeoutError` in nicegui/event.py:128 never caught it, so the '
              'descriptive-message wrapper never ran.')
        raise SystemExit(1)


asyncio.run(main())

Not run on this box. Verified the premise instead:

Implementation

Changed event.py:128 except TimeoutError -> except asyncio.TimeoutError (3.10 asyncio.TimeoutError != builtin).

Verification
  • Fail-first proven in docker python:3.
  • Local gates: pre-commit · mypy ./nicegui · pylint 10.00/10 · pytest (touched) — all green.

Progress

  • The PR title is a short phrase starting with a verb.
  • The implementation is complete.
  • This PR does not address a security issue.
  • Pytests have been added/updated.
  • Documentation is not necessary.
  • No breaking changes to the public API.

evnchn and others added 2 commits July 25, 2026 00:14
DateInput applied .props('no-parent-event') to the inner QDate, but
Quasar 2.18.5's QDate has no such prop (noParentEvent belongs to the
anchor-props mixin behind QMenu/QTooltip). It was an inert no-op
attribute; removing it changes no behavior (open/close is driven by the
parent QMenu).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
asyncio.wait_for raises asyncio.TimeoutError, which on CPython 3.10 is a
separate class from the builtin TimeoutError (they only became identical in
3.11). The `except TimeoutError` clause therefore never caught the timeout on
3.10, so a bare asyncio.TimeoutError with no message and no exception chaining
escaped instead of the intended descriptive TimeoutError. Catch
asyncio.TimeoutError so the wrapper fires on all supported versions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@evnchn evnchn added bug Type/scope: Incorrect behavior in existing functionality review Status: PR is open and needs review labels Jul 26, 2026
@falkoschindler falkoschindler self-assigned this Jul 26, 2026
@falkoschindler
falkoschindler self-requested a review July 26, 2026 20:37
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.

2 participants