winloop

A native Windows Fiber Scheduler for MRI Ruby, built on I/O Completion Ports.

winloop makes ordinary blocking code — socket reads and writes, sleep, Timeout.timeout, Mutex, Queue, Thread#join, DNS lookups — run cooperatively on a single thread. Wrap your code in Winloop.run { ... }, spawn fibers with Fiber.schedule, and thousands of connections share one thread without you touching a single nonblocking API.

require "winloop"
require "socket"

Winloop.run do
  server = TCPServer.new("127.0.0.1", 9292)
  loop do
    client = server.accept                 # readiness via AFD poll on the IOCP
    Fiber.schedule do                       # one lightweight fiber per connection
      while (line = client.gets)            # recv driven by the completion port
        client.write(line)                  # send, never blocking the loop
      end
      client.close
    end
  end
end

Why this exists

Ruby's Fiber::Scheduler (the engine behind socketry/async and friends) is the modern way to write concurrent I/O. But on Windows the selectors that back it — socketry/io-event ships select, epoll, kqueue, and io_uring — have no IOCP backend. The result has long been the weakest async story of any major platform: select()-bound, capped at 64 handles, and slow.

winloop fills that hole the same way libuv, Rust's mio, and wepoll do it:

  • The event loop is a real I/O Completion Port. One GetQueuedCompletionStatusEx call per turn reaps every ready event at once.
  • Readiness (accept, connect, IO#wait_readable) — which IOCP doesn't natively provide — is obtained by submitting IOCTL_AFD_POLL against \Device\Afd as an overlapped operation on the port. This is the undocumented mechanism libuv/mio rely on, and it is the only way to get readiness semantics onto a completion port.
  • Reads and writes are driven by recv/send once the port reports readiness.
  • Timers (sleep, Timeout.timeout) use a monotonic min-heap that sets the port's wait timeout — no kernel timer objects, no extra threads.
  • Mutex/Queue/Thread#join park the fiber on an in-process waiter list; a wakeup from another OS thread breaks the loop's wait via PostQueuedCompletionStatus.

Requirements

  • A native Windows MSVC build of Ruby (x64-mswin64). winloop compiles a C extension with cl.exe and links ws2_32. It will refuse to install on any non-mswin platform.
  • Ruby >= 3.1 (for a stable Fiber::Scheduler and IO::Buffer).
  • \Device\Afd readiness polling is a Windows NT facility; winloop is not expected to work under Wine.

Installation

gem install winloop

Or build from a checkout (it dogfoods the vcvars gem so it can find the MSVC toolchain without a Developer Command Prompt):

rake compile
rake test

What runs cooperatively

Inside Winloop.run { ... } (and any Fiber.schedule started within it):

Operation Hook Mechanism
TCPServer#accept, IO#wait_readable io_wait IOCTL_AFD_POLL overlapped on the IOCP
IO#read / gets / recv on a socket io_read recv_nonblock + io_wait
IO#write / puts on a socket io_write write_nonblock + io_wait
sleep kernel_sleep monotonic timer heap
Timeout.timeout timeout_after timer heap + Fiber#raise
Mutex, ConditionVariable, Queue, Thread#join block/unblock waiter lists + PostQueuedCompletionStatus
Addrinfo.getaddrinfo, DNS in TCPSocket.new address_resolve resolved on a worker thread
Process.wait process_wait reaped on a worker thread
your own OVERLAPPED ops (any gem) await_op generic completion packets on the same IOCP

API

Winloop.run { ... }   # install a scheduler, run the block in a fiber, drive the
                      # loop until every scheduled fiber finishes; returns the
                      # block's value (and re-raises anything it raised).

Winloop.supported?    # true when the native IOCP/AFD backend loaded.

For full control you can drive it yourself, exactly like any other Fiber::Scheduler:

scheduler = Winloop::Scheduler.new
Fiber.set_scheduler(scheduler)
Fiber.schedule { ... }
scheduler.close       # runs the event loop to completion

The generic-op surface (winloop 0.2 — see the next section):

# Scheduler (the cross-gem protocol; loop-thread-only):
sched.op_associate(handle)                        # => true   (permanent; handle stays yours)
sched.op_prepare(handle, tag: 0, capacity: 0)     # => [op_id, ov_addr, buf_addr]
sched.(op_id)                         # => true   (native call returned TRUE or ERROR_IO_PENDING)
sched.op_abandon(op_id)                           # => true   (native call failed synchronously)
sched.op_cancel(op_id)                            # => true | false (false: already completed, or CancelIoEx failed — warned, never raised)
sched.op_state(op_id)                             # => :prepared | :submitted | :completed
sched.await_op(op_id, timeout: nil)               # => [bytes, error, data] | nil (timeout; op auto-cancelled)

# Backend (the power layer; also the standalone-embedding layer):
backend.associate(handle)                         # => true
backend.op_prepare(handle, tag: 0, capacity: 0)   # => [op_id, ov_addr, buf_addr]
backend.(op_id)                       # => true
backend.op_abandon(op_id)                         # => true
backend.op_cancel(op_id)                          # => true | false
backend.op_result(op_id)                          # => [bytes, error, data] (frees the record)
backend.op_free(op_id)                            # => true (retire a completion nobody wants)
backend.op_state(op_id)                           # => :prepared | :submitted | :completed
backend.port_handle                               # => Integer (read-only; tests/diagnostics)
backend.wait(timeout_ms)                          # => [[id, events], ..., [op_id, bytes, error, tag], ...]
Winloop::EXTERNAL_KEY                             # => 0x45585431 — completion key of all generic ops
Winloop::OP_CAPACITY_MAX                          # => 16 MiB — op_prepare capacity ceiling

Bring your own OVERLAPPED (generic completions)

winloop 0.2 lets any gem associate a Windows handle with the loop's completion port, submit its own overlapped operation, and park the calling fiber until the completion packet arrives — cancel and synchronous-completion semantics done right, with zero change to everything winloop already does. This is a seam for gem authors (file watchers, named pipes, mailslots…), not app code.

The contract loop: op_associateop_prepare → your native submit → op_submitted (success or ERROR_IO_PENDING — both queue a packet) / op_abandon (synchronous failure — the only no-packet case) → await_op[bytes, error, data].

A real overlapped file read driven from plain Ruby via Fiddle (stdlib):

require "winloop"
require "fiddle/import"

module K32
  extend Fiddle::Importer
  dlload "kernel32.dll"
  extern "void* CreateFileW(void*, unsigned long, unsigned long, void*, unsigned long, unsigned long, void*)"
  extern "int ReadFile(void*, void*, unsigned long, void*, void*)"
  extern "int CloseHandle(void*)"
end

GENERIC_READ         = 0x8000_0000
FILE_SHARE_READ      = 0x1
OPEN_EXISTING        = 3
FILE_FLAG_OVERLAPPED = 0x4000_0000
ERROR_IO_PENDING     = 997

Winloop.run do
  sched = Fiber.scheduler                 # a Winloop::Scheduler
  wpath = (File.expand_path(__FILE__) + "\0").encode("UTF-16LE")
  h = K32.CreateFileW(wpath, GENERIC_READ, FILE_SHARE_READ, nil,
                      OPEN_EXISTING, FILE_FLAG_OVERLAPPED, nil).to_i
  sched.op_associate(h)
  op_id, ov, buf = sched.op_prepare(h, capacity: 4096)
  ok = K32.ReadFile(h, buf, 4096, nil, ov)
  if ok != 0 || Fiddle.win32_last_error == ERROR_IO_PENDING
    sched.(op_id)             # success AND pending both queue a packet
    bytes, error, data = sched.await_op(op_id, timeout: 5)   # fiber parks HERE
    raise Winloop::Error, "read failed (#{error})" unless error.zero?
    puts "read #{bytes} bytes: #{data[0, 40].inspect}"
  else
    sched.op_abandon(op_id)               # synchronous failure: no packet will come
    raise Winloop::Error, "ReadFile failed (#{Fiddle.win32_last_error})"
  end
  K32.CloseHandle(h)
end

Rules (read this twice)

  • Handles stay yours. winloop owns the op memory (OVERLAPPED + buffer, one backend-owned heap allocation); you open and close your handles — winloop never calls CloseHandle on them.
  • Association is permanent — one port per handle, no disassociation (Win32 rule). After the loop shuts down an associated handle can never join another winloop loop; re-open per Winloop.run session.
  • Associate before prepare. op_prepare rejects handles it has never seen, because an op on a handle that is not on the port never completes: not an error, a silent hang. The one case the check cannot catch — closing a handle mid-flight so the OS recycles its value — is on you: cancel → await → only then CloseHandle.
  • Use await_op timeouts while first integrating a new native API; switch to nil once the submit path is proven.
  • Never set FILE_SKIP_COMPLETION_PORT_ON_SUCCESS on an associated handle (it is irreversible and per-handle): winloop's invariant is exactly one packet per submitted op, always. Don't use ReadFileEx/WriteFileEx (APC I/O) on associated handles, and don't duplicate/inherit them.
  • Cancel never frees — the cancelled op still posts a packet (usually error 995), which is reaped normally.
  • After await_op returns — value or nil — the op id is dead. Don't use it again.
  • Loop thread only: ops are submitted on the loop thread (fibers run there anyway); the scheduler wrappers enforce it.
  • Completion errors are values, not exceptions: 995 cancelled, 1022 RDCW rescan, 38 EOF, … A zero-byte successful completion is a real signal (RDCW overflow ⇒ rescan), delivered verbatim.
  • No PQCS injection — out of scope in v1. Backend#port_handle exists for tests and diagnostics only.

Standalone (no scheduler)

Client gems never hard-depend on winloop. The protocol is duck-typed on the scheduler:

sched = Fiber.scheduler
if sched&.respond_to?(:await_op)
  # winloop (or compatible) IOCP path: op_associate / op_prepare / native
  # submit / op_submitted / await_op — as above.
else
  # the client gem's own standalone mechanism, e.g. the winipc pattern:
  # stack OVERLAPPED + manual-reset hEvent, WaitForSingleObject without the
  # GVL with ubf = CancelIoEx, GetOverlappedResult; or a gem-owned wait thread
  # for continuous streams.
end

winloop ships no shared standalone wait thread or service (dependency direction, irreversible association, ownership — clients own their threads). Winloop::Backend itself does work standalone — create, associate, prepare/submit, wait-poll (dispatch on tuple size: AFD 2-tuples vs op 4-tuples in the same array), op_result, shutdown — which is how an embedder or a test drives it deterministically.

Scope and limitations (v0.2)

  • Sockets are the focus. TCP/UDP sockets get the full IOCP/AFD path. Pipes and regular files fall back to a blocking read inside io_read (async file I/O on Windows is a separate mechanism, planned for later).
  • Single thread-of-use. One thread runs the loop and resumes fibers; other OS threads may safely wake it (e.g. a background Queue producer), but the loop itself is not a multi-threaded worker pool.
  • IOCTL_AFD_POLL is undocumented; winloop resolves its ntdll entry points at runtime and degrades by raising a clear error if \Device\Afd is unavailable.
  • The ops API is for gem authors. Buffers are backend-owned only (no IO::Buffer/String pinning — copy-out removes the whole lifetime/compaction bug class). Ops still in flight at shutdown are cancelled and drained for a bounded interval; stragglers may be deliberately leaked rather than freed under possible kernel ownership.
  • x64 only. arm64-mswin is expected to work (all code is _WIN64/ uintptr_t-clean, no arch-specific anything — AFD + IOCP are production-proven on arm64 via libuv/Node and mio/Rust) but is untested and unsupported until an arm64-mswin Ruby distribution exists.

How it compares

winloop is a drop-in Fiber::Scheduler, so it works with anything that targets the scheduler interface. It is not a reimplementation of async — it's the missing Windows engine such libraries can run on. The reference scheduler shipped with Ruby is intended for testing and is IO.select-bound; winloop replaces that selector with a true completion port.

License

MIT. See LICENSE.txt.