A correctness release with one transport improvement. Nothing on the wire
changes, nothing in the API is removed or renamed, and the one new
capability is additive and feature-detected through probe().capabilities.
A 1.8.x/1.9.x MoroJS keeps working on this binary; a MoroJS built for this
release keeps working on 1.1.6.
Fixed — callbacks now drain the microtask queue
Every trampoline into JS (onRequest, onRequestBatch, onAborted,
onWritable, onWsOpen, onWsMessage, onWsClose) runs inside a Node
callback scope, the same scope Node opens around its own I/O callbacks.
When the outermost scope closes, Node runs the process.nextTick queue and
drains V8's microtask queue.
Before this release the engine invoked callbacks with a plain V8 call, so a
continuation queued during dispatch — an await on an already-settled
promise, a .then chain, a framework's asynchronous not-found path — stayed
queued until some later Node-managed callback ran. On a quiet server that
was whatever timer fired next: a response sent from such a continuation
took seconds to leave (measured: 5–29 s for a 404 or a Promise.then
completion, and under load zero such responses completed). Completions that
arrived through setImmediate, timers or sockets were never affected, which
is why the bug hid behind real I/O.
capabilities.callbackScope: trueadvertises it. A framework that armed
its ownsetImmediatedrain as a workaround can skip it when this flag is
set (MoroJS does).- Cost per callback: an async-context push/pop and, with an empty queue, one
check. No measurable change in the raw hello-world numbers. - Test:
test/callback-scope.test.mjs— a response sent from a microtask, a
settledawaitand anextTickeach leave in well under a second; the
old binary fails the test by timing out.
io_uring — DEFER_TASKRUN behind a registered eventfd
The 1.1.6 measurements found io_uring halving the syscalls per request but
spending more CPU per completion than libuv, because under COOP_TASKRUN
every completion is its own task-work round trip. The ring now has two
modes (src/uring.h, "Ring modes"):
defer-taskrun(first choice): completions stay queued as local task
work until the engine's ownio_uring_enter(GETEVENTS)runs them as one
batch. Such a ring posts nothing to its fd, so the loop waits on a
registered eventfd, which the kernel signals when local work is queued —
one extrareadper wake, many completions per wake.coop-taskrun(the 1.1.6 mode): kept as the fallback, and selectable
withMORO_ENGINE_URING_TASKRUN=coopfor A/B runs
(MORO_ENGINE_URING_TASKRUN=deferpins the new one with no fallback).
The probe proves the wake behaviourally for whichever mode it selects,
exactly as it already proved the ring-fd wake, and probe().transportMode
reports 'uv', 'defer-taskrun' or 'coop-taskrun'.
Measured on the same VM as the 1.1.6 numbers (Docker Desktop, linuxkit 6.12,
arm64, server pinned to 2 cores, zrk closed-loop over keep-alive, two
rounds): defer beats coop by 13–30% in every cell with lower p99, and lands
at parity with libuv overall (ahead at 64 connections, behind at 256, split
at 512) where coop was 10–25% behind. libuv stays the default; io_uring
stays opt-in via MORO_ENGINE_TRANSPORT=uring until a run on the reference
hardware flips the go/no-go in docs/DESIGN.md.
Two follow-ups listed in the 1.1.6 design notes are closed without code:
ring-batched sends were already in place (a SEND is only prepared at issue
time; the reap loop's next enter submits the whole round), and
RECVSEND_BUNDLE cannot help a one-request-in-flight keep-alive shape.
Build
- The Linux build is warning-free from the source again: the six
(void)Function::Call(...)sites, which gcc flagged regardless of the
cast, consume the result explicitly.
Compatibility
- No wire changes. No removed or renamed exports (
tools/check-exports.mjs:
no drift). - New:
capabilities.callbackScope,probe().transportMode,
MORO_ENGINE_URING_TASKRUN. - Kernels without
DEFER_TASKRUNor whose eventfd wake the probe cannot
prove getcoop-taskrun; kernels or sandboxes without io_uring get libuv,
silently, as before. Verified: libuv, io_uring in both modes, and the
seccomp-blocked lane (test/ci/seccomp-block-io_uring.json) — 207 suite
tests each.