Skip to content

With one actor, a send runs the handler inside the sender, so a blocking handler deadlocks the program #3112

Closed

Description

@nicolas-maman

A send (!) is asynchronous: the sender goes on, and the actor handles the message on a scheduler thread. With only one actor, it isn't. The runtime's main-thread mode (docs/runtime-optimizations.md, "Main Thread Actor Mode") runs the first actor's handler synchronously inside the sender's !, until a second actor is spawned. So what a program does depends on how many actors it has:

message Drain { conn: ptr, want: int }
actor Reader { receive { Drain(conn, want) -> { /* read `want` bytes from conn */ } } }

main() {
    // ... a connected pair: client, conn ...
    r = spawn(Reader())
    r ! Drain { conn: conn, want: 1048576 }   // runs Reader's handler right here
    tcp.write_all(client, data)               // never reached: the reader waits for these bytes
}

That program hangs; spawn a second actor that does nothing and it runs.

  • A blocking handler blocks the sender. A server actor started with a ! Start {} never returns control to main, and a reader that waits for main deadlocks. The tests and examples avoid this by spawning a dummy actor first (spawn(SchedHelper()), spawn(Sched())), which 43 files in tests/ and contrib/ do for no other reason.
  • Order changes with the count. With one actor, the handler has finished before ! returns. With two, it runs later on another thread. Code that is correct with two actors can observe a different interleaving with one, and the reverse.
  • An embedding host loses its actors. When C holds the main thread, an actor in main-thread mode cannot run, so docs/c-embedding.md has the host call aether_scheduler_poll between frames to make progress.
  • The message payload points at the sender's stack (g_skip_free), which holds only because the handler finishes before the sender's frame does.

Fix: ! is asynchronous whatever the actor count. The threaded runtime delivers through the scheduler from the first actor on, and main-thread mode, its main_thread_only path and the stack-pointer payload are removed. The cooperative (no-threads) runtime queues a send and runs it at the next wait_for_idle or poll, as it does once there is more than one actor. The single-actor message path is measured before and after (the ping-pong and counter benchmarks), and the dummy actors in the 43 files are removed. The docs that describe main-thread mode, and the embedding guide's poll requirement for it, are updated.

Found on #3101 (#3108's test hung on it).

Activity

  1. nicolas-maman commented on Oct 26, 2026

    @nicolas-maman
    CollaboratorAuthor

    Fixed by bed800c, "A send is asynchronous whatever the actor count; send batches only wrap loops that cannot wait (#3112, #3117)", which merged with #3101 (the commit named the issue without a closing keyword, so it stayed open). Checked on main at 00df4c7 (0.848.0), MinGW gcc, Windows: tests/regression/test_send_async_one_actor.ae passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions