Skip to content

Async + Event Loop - #672

Draft
timkpaine wants to merge 5 commits into
mainfrom
tkp/async
Draft

Async + Event Loop#672
timkpaine wants to merge 5 commits into
mainfrom
tkp/async

Conversation

@timkpaine

@timkpaine timkpaine commented Jan 29, 2026

Copy link
Copy Markdown
Member

Putting this up for some discussion, we've been talking/integrating csp with lots of other frameworks, and many of them are based on asyncio. This PR adds some bridging utilities. For now they are mostly one direction, but we can also add the other direction which should be easy (e.g. running csp in a background thread and making edges awaitable / async generators).

import csp
from csp import ts
from datetime import timedelta, datetime
from typing import AsyncIterator
import asyncio


async def async_in() -> int:
    await asyncio.sleep(0.1)
    return 42

async def async_out(n: int) -> None:
    await asyncio.sleep(0.1)
    print(f"Output: {n}")

async def async_node(n: int) -> int:
    await asyncio.sleep(0.1)
    return n * 2

async def async_counter_node(n: int) -> AsyncIterator[int]:
    for i in range(n):
        await asyncio.sleep(0.1)
        yield i

@csp.node
def csp_counter_node() -> ts[int]:
    with csp.alarms():
        tick_alarm = csp.alarm(bool)

    with csp.state():
        s_counter = 0

    with csp.start():
        csp.schedule_alarm(tick_alarm, timedelta(), True)

    if csp.ticked(tick_alarm):
        s_counter += 1
        csp.schedule_alarm(tick_alarm, timedelta(seconds=0.1), True)
        return s_counter


@csp.node
def csp_async_node(trigger: ts[int]) -> ts[int]:
    """
    Example node that uses async operations with the alarm pattern.
    The completed coroutine wakes the node itself, so no polling alarm is needed.
    """
    with csp.alarms():
        async_alarm = csp.async_alarm(int)

    if csp.ticked(trigger):
        # Schedule async operation to double the counter
        csp.schedule_async_alarm(async_alarm, async_node(trigger))

    if csp.ticked(async_alarm):
        # Async operation completed
        return async_alarm


@csp.graph
def graph():
    csp_counter = csp_counter_node()
    async_counter = csp.async_for(async_counter_node(15))

    csp.print("counter", csp_counter)
    csp.print("async_counter", async_counter)

    # async_in: coroutine that ticks once when ready
    async_in_result = csp.async_in(async_in())
    csp.print("async_in", async_in_result)

    # async_out: invoke async function when input ticks
    csp.async_out(csp_counter, async_out)

    # async_node: takes input, runs async, outputs result
    async_node_result = csp.async_node(csp_counter, async_node)
    csp.print("async_node", async_node_result)

    # async for within CSP node
    csp.print("csp_async_node", csp_async_node(csp_counter))

if __name__ == "__main__":
    csp.run(graph, realtime=True, endtime=timedelta(seconds=2))

@timkpaine timkpaine added type: feature Issues and PRs related to new features tag: wip PRs that are a work in progress - converted to drafts labels Jan 29, 2026
@timkpaine
timkpaine force-pushed the tkp/async branch 4 times, most recently from 51f67ea to 09f328e Compare January 29, 2026 23:08
@timkpaine timkpaine changed the title Async bridges Async + Event Loop Jan 30, 2026
@timkpaine
timkpaine force-pushed the tkp/async branch 3 times, most recently from 5695b7d to ac2e73d Compare February 2, 2026 00:05
@timkpaine
timkpaine force-pushed the tkp/async branch 14 times, most recently from 630f881 to f39ab39 Compare February 24, 2026 21:51
@timkpaine
timkpaine force-pushed the tkp/async branch 6 times, most recently from d92c799 to 0c8f2e6 Compare February 26, 2026 14:34
@timkpaine
timkpaine force-pushed the tkp/async branch 4 times, most recently from b1d21db to ed71f55 Compare February 27, 2026 21:23
Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
@timkpaine

Copy link
Copy Markdown
Member Author

5 critical issues fixed:

  • Ctrl-C is broken for realtime runs; process_one_cycle violates the Python C-API contract
  • PyEventLoopAdapter::callSoonThreadsafe touches the Python C-API without the GIL
  • PyEventLoop.{h,cpp} is dead code
  • FdWaiter lost wakeup: the de-dup flag is incompatible with clear-after-process
  • FdWaiter is destroyed while producer threads may be inside notify()

Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
@timkpaine

Copy link
Copy Markdown
Member Author

More key fixes:

  • call_soon_threadsafe never wakes the loop (self-pipe never created)
  • CspEventLoop restarts an already-finished engine
  • Adapter threads can push_tick() into a stopped engine
  • await_(..., loop=current_loop) deadlocks the loop thread
  • _run_on_async_loop deadlocks on cancellation
  • Positive-delay asyncio timers never fire in simulation mode
  • m_cycleCount now counts cycles that do no work
  • Windows socket handles are truncated to int
  • macOS pipe fds are not close-on-exec
  • nextScheduledTime() returns a sentinel that never compares equal to None
  • Pervasive silent exception swallowing
  • Async-alarm codegen materialises a type by name lookup
  • visit_Name rewrites async-alarm names in Store/Del context
  • get_shared_loop() can create two loops and orphan a thread
  • Unbounded queues and task lists, no backpressure
  • Socket-operation cancellation leaks selector registrations
  • _scheduled timers: cancelled handles never reaped, pop(0) is O(n)
  • CspEventLoop.time() switches clock domains mid-run
  • Timing-based performance assertions will flake in CI
  • run_on_thread silently opts into the new async path
  • _run_asyncio_engine's error path masks the original exception

… fd test

Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
Async alarms were poll-only: nothing woke the node when a coroutine
finished, so a node with no other ticking input never delivered results
and every example had to hand-roll a 10ms csp.alarm loop. When an engine
loop exists the alarm now borrows it, which puts completions on the
engine thread and lets them fire the alarm's own slot directly. Failures
wake the node too, so an exception surfaces instead of sitting in the
queue. Without an engine loop the alarm keeps its background thread and
the old poll semantics.

csp.ticked() on an async alarm also consumed a result per evaluation,
because get_result() dequeued. A per-cycle latch makes it idempotent,
cleared by a start_cycle() call injected immediately after the per-cycle
yield, which is the one point that runs exactly once per invocation.

csp.run(realtime=True) accepted queue_wait_time and discarded it, cleared
the caller's event loop instead of restoring it, and reported a nested
call as "Cannot run the event loop while another loop is running". All
three are fixed; the loop is captured without the event loop policy,
which is deprecated in 3.14 and removed in 3.16.

async_node queued input without limit, so a slow function grew memory
until it ran out. maxsize and on_overflow are now explicit and default
to the previous unbounded behaviour. maxsize bounds in-flight work as
well as queued work, since bounding only the queue moves the backlog
into the task set rather than removing it. The 10Hz shutdown poll is
replaced by a sentinel, with cancellation still guaranteeing termination.

Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tag: wip PRs that are a work in progress - converted to drafts type: feature Issues and PRs related to new features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant