BUGRAMMING | ENGINEERED SYSTEMSFeather: A Hybrid Async Runtime for Jai | Bugramming
Back to index

Feather: A Hybrid Async Runtime for Jai

2026.11.12•ASYNC RUNTIME

For the past several months, I've been writing low-level network services in Jai. Every time you step into asynchronous programming in systems languages, you run straight into an ideological wall. You are forced to choose between two warring camps: Rust's zero-cost but viral async/await state machines, or Go's clean but memory-hungry goroutine stacks.

It took me five iterations of throwing away runtimes before I stopped trying to force my code into one camp or the other. What if we looked at the problem through a different lens? What if we gave framework listeners a stackless model to keep idle memory negligible, while giving user-facing handlers stackful fibers so developers can write clean, synchronous-looking code?

"It took five complete rewrites to settle on this architecture. Believe me, my Git commit log is a graveyard of dead experiments."

Today, I want to share Feather: an experimental hybrid asynchronous runtime module I've been building for the Jai programming language that explores unifying both models on the exact same thread pool.

Here is what we are covering:

  1. The Familiar Dilemma: Exploring the friction between colored functions and stack bloat.
  2. The Hybrid Exploration: How Feather bridges stackless Futures and stackful Fibers using a zero-offset struct layout trick.
  3. Transparent Asynchrony: Writing blocking-looking TCP code that cooperatively yields without async or await.
  4. Stack Swap: Assembly context switching (fiber_swap) and the multi-threaded fiber migration trap.
  5. The 4-State Lock-Free Scheduler: Coordinating worker threads and kernel reactors without mutexes on the hot path.
  6. Try It in 60 Seconds: A 30-line TCP echo server that compiles in 0.26 seconds.

1. The Familiar Dilemma: Colored Functions vs. Stack Bloat

To understand what Feather is exploring, let's look at the compromise every backend engineer currently navigates.

The Stackless Camp (Rust, Node.js, C#)

When you write async fn in Rust, the compiler transforms your function into a state machine.

  • The Good: Zero stack memory overhead. A sleeping future consumes barely a few dozen bytes. You can hold 500,000 idle TCP sockets on a cheap laptop without breaking a sweat.
  • The Ugly: The Function Coloring Problem. If a low-level helper function needs to read from a socket, it must become async. Now every function that calls it must become async. The keyword spreads like an infection through your entire codebase. You end up maintaining two separate ecosystems: a sync world and an async world that cannot talk to each other.

The Stackful Camp (Go, Erlang)

Go rejects colored functions. Every goroutine is allocated its own stack. You write straightforward, synchronous-looking code.

  • The Good: Unbeatable ergonomics. You call net.Conn.Read() anywhere in your code, ten levels deep in your business logic, and the runtime automatically pauses the goroutine and swaps the OS thread.
  • The Ugly: Memory overhead. Every single task demands a stack. Go starts stacks at 2 KB and resizes them dynamically (introducing compiler stack-check preambles). In C or Jai, allocating a fixed 64 KB or 512 KB fiber stack per connection means 100,000 idle connections will consume 50 Gigabytes of RAM just sitting there.
ApproachDeveloper ErgonomicsMemory per Idle TaskFunction Coloring?
Stackless (Rust)High friction (async/await everywhere)Tiny (~16-64 bytes)Yes (Viral)
Stackful (Go)Great (Linear blocking code)Heavy (2 KB to 512 KB)No
Feather (Hybrid)Linear for handlers, lightweight for listenersDynamic (Fibers or Futures)No

"Is pairing them together the ultimate answer? We cannot say that yet. But when an acceptor only needs to poll a socket once in a while, while a handler needs to query a database several layers deep, forcing the exact same memory structure on both felt worth questioning."


2. The Hybrid Exploration: Subtyping by Memory Layout

In Feather, the core engine (the Executor and the Reactor) knows absolutely nothing about CPU registers, call stacks, or assembly. It only understands one primitive: a stackless Future.

A Future in Feather is brutally simple:

jai
Future :: struct { poll: (*Future) -> Poll_State; schedule_state: Future_Schedule_State; executor: *Executor; }

It carries a single function pointer (poll). When an operation cannot finish, poll returns .PENDING. When it finishes, it returns .READY. There is no vtable, no fat pointer, and no dynamic dispatch.

So how do we run a stackful, register-swapping coroutine (Fiber) on this exact same engine?

We wrap it in a bridge struct:

jai
Fiber_Future :: struct { #as using base: Future; // MUST be at byte offset 0 fiber: *Fiber; }

Jai's #as using base: Future directive does two critical things:

  1. It places base at byte offset 0. A pointer to a Fiber_Future is numerically identical to a pointer to a Future.
  2. It allows implicit casting. Anywhere the runtime expects *Future, you can pass *Fiber_Future.
text
Memory Layout of Fiber_Future: +-----------------------------------+-------------------+ | Future.poll | schedule_state | *Fiber pointer | | (8 bytes) | (4 bytes + pad) | (8 bytes) | +-----------------------------------+-------------------+ <----------- offset 0 ------------->

When the Executor picks up a Fiber_Future, it calls fut.poll(fut). That function executes fiber_future_poll:

jai
fiber_future_poll :: (base: *Future) -> Poll_State { self := cast(*Fiber_Future) base; // Physically switch the CPU context onto the fiber's private stack! fiber_resume(self.fiber); if self.fiber.finished { fiber_destroy(self.fiber); return .READY; } // The fiber paused mid-execution waiting on I/O! return .PENDING; }

The executor doesn't know it just swapped CPU stacks. It thinks it ran a normal poll step.


3. Transparent Asynchrony: Context-Aware I/O

One of my biggest pet peeves in modern systems is having two distinct networking APIs: std::net for synchronous code and tokio::net for asynchronous code.

In Feather, there is one TcpStream, one stream_read, and one stream_write.

We achieve this using Jai's first-class context system. Every Jai thread has an implicit context passed down the call chain. Feather injects a runtime pointer into context.feather.

Here is the exact implementation of stream_read:

jai
stream_read :: (stream: *TcpStream, buffer: [] u8) -> (bytes_read: s64, err: Error) { // Branch 1: Called from standard thread outside Feather? if !context.feather { bytes := recv(stream.fd, buffer.data, cast(u64) buffer.count, 0); if bytes == -1 return 0, ERR_STREAM_READ; return bytes, ERR_NONE; } // Branch 2: Called inside a Feather Fiber! while true { bytes_read, status := read_async(stream, buffer); if status == .PENDING { // Register socket interest with epoll/kqueue and yield the stack! fiber_yield(); continue; } else if status == .ERROR { return 0, ERR_STREAM_READ; } return bytes_read, ERR_NONE; } }

Look at what this means for application code. When you write a business logic handler, you don't use async or await. You write plain, linear code:

jai
// Inside a connection fiber buf: [1024] u8; bytes, err := stream_read(*stream, buf); // Looks blocking!

If data is already buffered in the OS socket, it returns immediately with zero context switches. If the socket would block (EWOULDBLOCK), it registers the socket with the Reactor and calls fiber_yield(), freeing the OS thread to do other work.


4. Stack Swap: Assembly & The Thread Migration Trap

When you build a fiber runtime, you do not use ucontext_t or OS fibers unless you enjoy issuing slow kernel transitions. Feather switches stacks using hand-rolled assembly:

Because of standard ABI calling conventions, we don't have to save all 32 registers. We only preserve callee-saved registers (x19-x30, d8-d15, and SP on ARM64).

The Concurrency Nightmare: Thread Migration

Here is a bug that took me days to diagnose.

Imagine this scenario:

  1. A fiber starts executing on Worker Thread A.
  2. It hits stream_read, registers interest, and calls fiber_yield(). The CPU swaps back to Thread A's scheduler stack.
  3. 5 milliseconds later, packet data arrives on the network card.
  4. Feather uses a work-stealing thread pool. Worker Thread B is idle, steals the fiber, and resumes it via fiber_resume.

The fiber's heap stack memory is intact. But where do Jai's context pointers point?

They point to Worker Thread A's stack!

If the fiber finishes or yields again, it will read context.scheduler_context from the old thread, jump into a stale stack frame on Core 0, and crash with an incomprehensible memory fault.

Here is how we solved it in Feather/fiber.jai:

jai
fiber_yield :: () { fiber := context.current_fiber; // Jump back to the LATEST scheduler context fiber_swap(*fiber.context_buf, fiber.jai_context.scheduler_context); // ✨ WHEN WE WAKE UP ON A NEW THREAD: // Update local thread variables from the heap-allocated Fiber struct! context.scheduler_context = fiber.jai_context.scheduler_context; context.waker = fiber.jai_context.waker; context.feather = fiber.jai_context.feather; }

Whenever Thread B resumes a fiber, it stamps its own scheduler addresses into the fiber's heap structure. The moment the fiber wakes up from fiber_swap, it resynchronizes its stack registers.


5. The 4-State Lock-Free Scheduler

The hardest problem in asynchronous engines is the race condition between:

  • The Reactor thread: "Socket 4 is ready! Wake up the future!"
  • The Worker thread: "I am currently polling this future right now!"

If the Reactor tries to enqueue the future into the thread pool while a worker is already executing it, you get double-enqueue bugs, corrupted linked lists, or tasks running concurrently on two threads at once.

Feather coordinates this with an atomic 4-state lifecycle (Future_Schedule_State):

text
IDLE ──(wake)──> QUEUED ──(worker picks up)──> RUNNING │ wake() called while RUNNING │ RUNNING ──(CAS)──> RUNNING_AND_WOKEN │ │ │ worker finishes poll │ │ RUNNING_AND_WOKEN ──(CAS)──> QUEUED ──(spawn)

A look at how this behaves:

  1. When a task is sleeping, it is IDLE.
  2. When the kernel signals readiness, wake() atomically transitions IDLE → QUEUED via Compare-And-Swap (CAS) and pushes it to the work queue.
  3. When a worker thread picks it up, it sets the state to RUNNING.
  4. The Critical Race: If wake() fires while the task is already RUNNING, the reactor does not touch the work queue! It simply CAS-flags the state to RUNNING_AND_WOKEN.
  5. When the worker finishes its polling step, it inspects its own state. Seeing RUNNING_AND_WOKEN, it re-enqueues itself.

6. Try It in 60 Seconds: A 30-Line Echo Server

Enough theory. Let's run some code.

Here is a concurrent TCP echo server using Feather (examples/01_echo_server.jai):

jai
#import "Basic"; #import "Socket"; #import "Feather"; client_handler :: () { stream: TcpStream; stream.fd = cast(Socket) context.workspace; defer close(stream.fd); buf: [1024] u8; while true { slice: [] u8 = .{ count = buf.count, data = buf.data }; bytes_read, err := stream_read(*stream, slice); if err.kind != .NONE || bytes_read == 0 break; out_slice: [] u8 = .{ count = bytes_read, data = buf.data }; _ = stream_write(*stream, out_slice); } } main :: () { feather_main(); defer feather_destroy(); feather_spawn(() { listener, _ := setup_tcp_listener(8080); defer close(listener.fd); print("Echo server listening on 0.0.0.0:8080\n"); while true { stream, err := stream_accept(*listener); if err.kind != .NONE continue; new_ctx := context; new_ctx.workspace = cast(*void) stream.fd; push_context new_ctx { feather_spawn(client_handler); } } }); keep_running := true; feather_run(*keep_running); }

Compile & Run

Compile this with Jai:

bash
$ jai examples/01_echo_server.jai

Test it with nc:

bash
$ nc 127.0.0.1 8080 hello feather! hello feather!

What's Rough Right Now (The Honest Truth)

I don't believe in presenting experimental software as production-ready perfection. Feather is fast, but it has sharp edges I am actively working through:

  1. Timers are an O(N) Scan: The timer wheel in reactor.jai currently scans a flat array under a mutex on every tick. This works fine for hundreds of timers, but will choke at 50,000. It needs to be replaced with a hierarchical hashed timing wheel.
  2. No Stack Guard Pages: Fiber stacks are allocated from a thread-safe free-list (Fiber_Stack_Pool), but they don't yet have mprotect PROT_NONE guard pages. If you blow through your 512 KB stack with runaway recursion, you will silently corrupt memory instead of catching a clean trap.
  3. Windows IOCP: Feather currently supports macOS (kqueue) and Linux (epoll). Windows IOCP is fundamentally a completion model (the kernel writes directly into your buffer) rather than a readiness model (the kernel tells you when you can read). Bridging this without compromising the API is ongoing work.

Closing Thought

For years, the systems community has viewed stackless futures and stackful coroutines as an either-or choice. You pick your runtime, buy into its trade-offs, and structure your entire architecture around its limitations.

Feather is not claiming to have solved this dilemma once and for all. It is an opinionated experiment. It gives developers the freedom to write synchronous-looking, ergonomic code in user-facing APIs without paying the stack allocation tax across the entire server lifecycle. But until it is battle-tested by many people under unpredictable production workloads, it remains just that: an experiment.

We are not presenting a definitive answer. We are simply exploring a different way to look at the dichotomy.

Whether this hybrid approach proves to be rock-solid and viable for production systems is something only real-world adoption will tell. For now, it opens up a compelling design space for Jai. And in systems programming, having another tool to rethink old trade-offs is where the real fun begins.


Feather is open source under the MIT License. Check out the repository on GitHub or look at Falcon, the companion HTTP framework built on top of it.