Skip to content

Async + Event Loop - #672

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

Async + Event Loop#672
timkpaine wants to merge 7 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>
A signal handler runs on the thread it interrupts, and ours calls
call_soon_threadsafe, which takes _csock_lock. When the signal arrived
while that same thread already held the lock the process deadlocked, so
the lock is now reentrant.

csp.ticked() is a plain predicate everywhere else in csp, but on an async
alarm it could raise the coroutine's exception. The failure is now
latched and raised where the node reads the alarm's value, and an error
the node never reads is logged rather than dropped.

The shutdown sentinel left the async_node concurrency semaphore held on
its way out, the engine ran a full cycle on every loop wakeup rather than
only when a wakeup was signalled or an event was due, and the FastAPI
example served an unauthenticated state-mutating endpoint on 0.0.0.0.

The async_alarm docstring told users to call stop() themselves, which
fails: the alarm name is rewritten to a value read, so aa.stop() becomes
an attribute lookup on the result. The generated stop block already does
this, verified by running graphs on both the borrowed-loop and owned-loop
paths without leaking a thread. __all__ no longer advertises helpers that
csp does not re-export.

Tests: bridge tests now register cleanup so a failed assertion cannot
leak the loop thread, wait on conditions instead of fixed sleeps, and
scale the call_later bound by the platform timer resolution. Adds
coverage for the parser's assign/delete guard, for alarm types that have
no __name__, for the new ticked() semantics, and an FdWaiter
construct/destroy-under-load stress test.

Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
Asyncio-Integration.md and Event-Loop-Integration.md were near-identical:
same title, 803 and 796 lines, 25 of 28 code blocks and 69 of 77 headings
byte-identical. Neither was a superset, so deleting either would have
lost content: one carried Part 0 on same-thread asyncio mode and the
comparison table, the other carried simulation mode and the matching
CspEventLoop constructor and set_simulation_time_range reference.

Fold both sets into Event-Loop-Integration.md, whose filename matches the
title the two shared, and drop the other along with its sidebar entry.
Every heading and code block from the deleted page is present in the
merged one.

Async.md is a different document, about the adapters rather than the loop,
and shares no code with either. It restated how same-thread mode works, so
that is now a link to the page that owns the topic.

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