Skip to content

EventProcessor.stop() partially cancels same-token event bursts instead of draining them #6755

Description

@FarhanAliRaza

Problem

During graceful shutdown, EventProcessor.stop() only drains events whose handler tasks are already running. Events still waiting in a per-token sequential queue are discarded and their futures cancelled — even when graceful_shutdown_timeout has plenty of budget left.

With 10 same-token no-op events enqueued and a generous drain budget, only ~2-3 events complete; the rest are cancelled. With 10 independent tokens, all 10 drain cleanly.

Mechanism

(packages/reflex-base/src/reflex_base/event/processor/event_processor.py)

Same-token (non-background) events are serialized through _token_queues: only the front entry per token has a task in self._tasks at any moment (_enqueue_for_token / _dispatch_next_for_token). The next task is only created by _finish_task when the previous one completes.

stop() drains in phases:

  1. join() — drains the main asyncio queue, but that only moves entries into _token_queues, so it returns almost immediately.
  2. _stop_tasks(timeout=...) — iterates as_completed(self._tasks.values(), ...) over a snapshot of currently-running tasks. Tasks dispatched afterwards by _finish_task chaining are not awaited; each _stop_tasks pass effectively lets ~1 more event per token finish.
  3. self._token_queues.clear() — all still-queued same-token entries are discarded.
  4. Remaining futures are cancelled.

So the drain budget is never actually applied to the per-token backlog.

Repro

import asyncio

from reflex_base.event.processor import EventProcessor
from reflex_base.registry import RegistrationContext

from reflex.event import Event, EventHandler


async def noop() -> None: ...


ctx = RegistrationContext.get_or_create_for_testing()
ctx.__enter__()
handler = EventHandler(fn=noop)
ctx.register_event_handler(handler)  # however registration is spelled; see tests/benchmarks/test_event_loop.py::_register_handlers


async def main():
    processor = EventProcessor(graceful_shutdown_timeout=60).configure()
    async with processor:
        futures = [
            await processor.enqueue("token", Event.from_event_type(handler())[0])
            for _ in range(10)
        ]
    done = sum(f.done() and not f.cancelled() for f in futures)
    print(f"{done}/10 drained")  # ~3/10; expected 10/10


asyncio.run(main())

A working registration setup lives in tests/benchmarks/test_event_loop.py (_register_handlers). test_event_shutdown_drain there deliberately uses independent tokens and documents this bug as the reason.

Expected

While the drain budget has time remaining, stop() should keep processing per-token queues (e.g. loop _stop_tasks while self._tasks or _token_queues are non-empty and the deadline hasn't passed) before clearing _token_queues and cancelling futures.

Found while adding drain assertions to the event-loop benchmarks in #6751.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions