Skip to content

v1.1.6

Choose a tag to compare

@M-Chris M-Chris released this 14 Sep 01:16
· 7 commits to main since this release

@morojs/engine 1.1.6

The JS boundary release. Nothing on the wire changes except one header line,
nothing in the API is removed or renamed, and every new native function is
additive and feature-detected through probe().capabilities. MoroJS 1.9.0
consumes it; a 1.8.x framework keeps working on this binary and a 1.9.0
framework keeps working on 1.1.x binaries.

Performance — the boundary, not the parser

  • Prepared response templates (capabilities.responseTemplates):
    prepareResponse(serverId, status, headersFlat) materialises the fixed part
    of a response once; respondPrepared(reqId, tplId, body) replays it per
    request. The per-request header walk (two property reads per header,
    validation, hop-by-hop filtering, Content-Length parsing) is gone from the
    hot path. respondPreparedEmpty, writeHeadPrepared and endWith round it
    out. Static routes (setStaticRoute, capabilities.staticRoutes) are a
    template plus a fixed body answered inside the engine before the request
    reaches JS.
  • V8 fast API calls (capabilities.fastCalls, probe().fastApi): the hot
    entry points — respondPrepared, respondPreparedEmpty, writeHeadPrepared,
    write, end, endWith, isAborted — carry a fast-call target. An
    optimised caller (Maglev/TurboFan) reaches the engine's C++ directly, with no
    FunctionCallbackInfo, no HandleScope and no argument boxing, whenever the
    arguments are already machine-typed (Smi ids, a sequential one-byte body).
    Everything else takes the regular callback, which does identical work. Node's
    headers tarball omits v8-fast-api-calls.h; the build fetches the exact
    per-tag copy (sha256-pinned in tools/build.mjs) and the targets install only
    when the host V8's major.minor matches the compiled one.
  • Zero-copy string bodies: on Node 23+ a one-byte string body is borrowed
    straight out of the V8 heap through String::ValueView (no copy, no size
    cap); Latin-1 bytes are UTF-8-encoded into a reused buffer. The Node 25/26
    builds regain the malloc-free string path (the old WriteOneByte bail-out
    on V8 ≥ 14 is replaced by WriteOneByteV2).
  • The FIN travels with the last bytes of a Connection: close response
    (macOS TCP_NOPUSH around the send then shutdown, Linux MSG_MORE +
    shutdown): the peer can no longer close first and inherit the TIME_WAIT.
    On macOS, where a closed port stays unusable for 30 s, a
    one-connection-per-request load against the engine decayed to ~5.8k conn/s
    as the client drained its ephemeral ports; it now sustains 27.5k, ahead of
    Bun and uWebSockets.js on the same harness. Linux churn gains the same
    guarantee.
  • One syscall less per accepted connection: TCP_NODELAY is set once on
    the listening socket and inherited by every accepted socket (Linux and
    XNU both do; test/sockopt-unit.cpp checks the running kernel), instead of
    a setsockopt per accept. Windows keeps the per-socket call.
  • Lingering close (RFC 9112 §9.6): after that FIN the socket stays open,
    with its input discarded, until the peer's own FIN (or 2 s). A client that
    writes its next request the instant a response completes, before it has
    processed the FIN — autocannon does — used to have that request answered
    by the kernel with a RST, which can also discard the response it had not
    read yet: one error and two reconnects per connection. Now the stray
    request is absorbed and the connection ends cleanly, as node:http and
    uWebSockets.js already did. Lingering connections count against
    maxConnections and are bounded by the deadline.
  • HTTP/1.1 keep-alive responses no longer carry Connection: keep-alive
    (persistence is the HTTP/1.1 default, RFC 9112 §9.3): 24 fewer bytes per
    response. HTTP/1.0 keep-alive still affirms it; every close path is unchanged.
  • Batched pipelined dispatch (capabilities.batchDispatch): a consumer
    that registers onRequestBatch(count) receives complete pipelined requests
    as one call over getBatchBuffers() descriptors (reqId, method, interned
    path index) and answers them in order, following a control cell the engine
    advances as each response completes; one JS crossing per batch instead of
    one per request, with the same byte stream as sequential dispatch
    (test/batch-dispatch.test.mjs). MoroJS 1.9.0 and the raw benchmark
    servers use it: +4-5% pipelined throughput on the reference box, plain
    traffic unchanged (docs/DESIGN.md). MORO_ENGINE_BATCH=0 turns it off.
  • io_uring transport on Linux, opt-in (MORO_ENGINE_TRANSPORT=uring,
    probe().transport): on Linux 6.1+ the engine can run its sockets on an
    io_uring (src/uring.h, hand-rolled, no liburing): multishot accept,
    multishot recv into kernel-provided buffers (parsed in place, no copy into
    a read buffer), one in-flight SEND per connection,
    shutdown-then-cancel-close teardown, one ring per loop thread driven from
    the same libuv loop. It is probed once per process — the required setup
    flags and features, an opcode probe, a provided-buffer ring, and an
    epoll-wake self-test on a socketpair — and any refusal keeps libuv
    silently: kernels before 6.1, gVisor, and containers under Docker's default
    seccomp profile (which blocks io_uring_setup; the reason is in
    probe().transportReason). The bytes on the wire are identical on both
    transports (test/transport-parity.test.mjs), and both run the whole
    suite, the sanitizers, and a connection-churn soak in CI. It halves the
    syscalls per request (2.0 → 1.0 keep-alive, 8 → 3 per connection) but on
    the reference box costs more CPU per completion wherever few completions
    batch per loop turn, so libuv stays the default; the measurements and the
    follow-ups that could flip that are in docs/DESIGN.md and
    docs/ROADMAP.md.
  • PGO release binaries: darwin, linux-gnu and linux-musl binaries are built
    with clang + lld and profile-guided optimisation (tools/pgo.mjs: instrument
    → train on the engine's own workload + the test suites → merge → optimise),
    verified by a strict CI lane. A profile problem never fails a build; the plain
    build ships instead.

Wire-byte identity across respond(), respondPrepared() and static routes,
across statuses, header sets, ASCII/Latin-1/two-byte/Buffer bodies, GET/HEAD,
keep-alive/close/HTTP-1.0 and pipelining, is proven by test/wire-parity.test.mjs.

Behaviour changes

  • onAborted / onWritable are delivered on a later loop turn
    (capabilities.asyncNotify), never re-entrantly from inside a
    respond()/writeHead()/write()/end() call. Before, a write failure or a
    responseBackpressureLimit trip inside respond() reached onAborted in the
    middle of that very call. isAborted(reqId) is already true inside
    onAborted. The one exception: close() delivers every pending onAborted
    synchronously before it returns. MORO_ENGINE_NOTIFY=sync restores the old
    delivery for bisecting (fast calls are then disabled).
  • A Connection: close exchange ends with a half-close, not an immediate
    close()
    : the server's FIN still goes first (with the response bytes),
    but the fd is released only when the peer's FIN arrives or 2 s pass. A
    client that inspects the server side will see the connection in
    FIN_WAIT_2 for the microseconds a normal peer takes to close; one that
    never closes holds a connection slot for 2 s instead of 0. The connection
    sweep now always runs (at 1 s granularity by default; it is unref'd and
    never keeps the process alive), even when every timeout is set to 0.
  • Safe inside worker_threads (capabilities.workerThreads): a server
    still open when its thread's environment is torn down (worker.terminate(),
    process.exit() inside a worker, an uncaught error) is closed by an
    environment cleanup hook. Previously that aborted the whole process with
    uv_loop_close() while having open handles.

Diagnostics and tooling

  • probe() gains notify, transport / transportReason, fastApi { compiled, installed, reason, compiledV8, runtimeV8 }, fastCallStats (with
    MORO_ENGINE_FASTCALL_STATS=1), and the capabilities staticRoutes,
    responseTemplates, asyncNotify, workerThreads, fastCalls,
    batchDispatch.
  • Kill switches (diagnostics only, never required): MORO_ENGINE_FASTCALL=0,
    MORO_ENGINE_NOTIFY=sync, MORO_ENGINE_BATCH=0, MORO_ENGINE_TRANSPORT=uv; build-time
    --no-fast-api; MORO_ENGINE_BINARY points the loader at a specific
    .node (the PGO training run uses it).
  • npm run check:exports (tools/check-exports.mjs) diffs the native surface
    across src/binding.cpp, index.js, index.mjs and index.d.ts; it runs
    in CI and in the release gate.
  • npm run test:suites is the single list of node --test suites every CI and
    release lane runs. New suites: notify-deferred, worker-threads,
    templates, wire-parity, fast-api, static-routes, probe-transport,
    transport-parity, batch-dispatch; npm run test:soak (connection churn with aborts and
    non-readers); new C++ units: text-unit, response-template-unit,
    uring-fake-unit (a fake kernel behind the ring, every OS) and
    uring-unit (the real ring, Linux); a libFuzzer target for the ring.
  • CI: an in-repo h1spec job (33/33), a strict PGO lane, kill-switch variant
    lanes, io_uring lanes on x64 and arm64 (Node 20-26), a seccomp-blocked
    fallback lane, a musl-on-io_uring lane, sanitizers on both transports, and
    Linux shipping lanes on clang-18 + lld.
  • tools/dev-linux.sh builds and tests inside a Linux container from any
    host (io_uring, musl, and the seccomp fallback without a CI round trip).

Upgrading

Nothing to do. Consumers that pass null headers or call respond() see the
same bytes as before minus the keep-alive line. If your code relied on
onAborted arriving synchronously inside respond(), it now arrives a turn
later (and isAborted() is already true when it does).