Skip to content

Prepare 0.0.2 six-platform release - #2

Merged
nanofuxion merged 89 commits into
mainfrom
codex/release-0.0.2
Sep 1, 2026
Merged

Prepare 0.0.2 six-platform release#2
nanofuxion merged 89 commits into
mainfrom
codex/release-0.0.2

Conversation

@nanofuxion

Copy link
Copy Markdown
Contributor

Bumps SXN to 0.0.2 and updates user-facing/docs and LLM metadata.

Adds cross-dependency CMake support needed for reproducible Windows cross-builds and updates release packaging to include Windows ZIP assets expected by install.ps1.

Verified: macOS arm64/x64 and Linux x64/arm64 builds pass 102/102 tests; Windows arm64/x64 binaries are cross-compiled and format/version verified.

nanofuxion and others added 30 commits August 30, 2026 22:18
…ay out

QuickJS installs console.log and nothing else, so any ported code calling
console.error died on a missing function -- and process.stderr, which
node_compat built on console.error, was broken the same way for the same
reason. There was no JS-visible write to fd 2 at all, so a diagnostic
either threw or landed in the program's own stdout.

__sxnWriteStderr is that write. console.error/warn go through it, and
process.stderr's sink now goes straight to it rather than back through
console.error, which would be a cycle.

Also stop SSE writing "undefined" as an event name or id. The framing code
took JS_ToCString of the event's `event` and `id` properties without
checking for undefined first, and then branched on the resulting pointer
being non-NULL -- which it always was. res.sse("tick", data) with no id
emitted a literal `id: undefined` line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The server dispatched on the first read callback and stopped reading. A
request that did not fit in one 64KB buffer -- an ordinary 1MB JSON POST, a
file upload, anything past a few hundred rows -- reached the handler with
its body cut off mid-byte, so JSON.parse threw and the handler answered 500.
There was no size at which this was reported: the request simply arrived
wrong.

Reads now accumulate into the connection until the head is complete and the
Content-Length it announces has arrived, and only then dispatch. Beyond 64MB
the request is refused rather than buffered without limit.

Content-Length is parsed by its own non-destructive scan. header_value(),
the existing helper, returns an interior pointer and overwrites the header
line's CRLF with a NUL, which is fine once a request is complete and about
to be parsed and wrong while it is still arriving -- it truncated the buffer
being accumulated, and its result cannot be freed.

The body reaches JS as a counted string (JS_NewStringLen). It could contain
a NUL byte, and strlen would have cut it there. Outbound fetch had the same
bug in the other direction: the request body was strdup'd and measured with
strlen, so a body with a NUL byte was sent truncated.

Also raise the listen backlog from 64 to 511, Node's value. At 64, a burst
of concurrent clients got connection-refused rather than queued -- visible
as ~2% failed requests under a 125-connection load test.

tests/fixtures/serve_large_body.mjs covers 1KB through 4MB bodies and the
NUL byte, in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The server answered every request with `Connection: close` and hung up. That
is legal HTTP/1.1 and ruinous in practice: a client doing sustained requests
opens a TCP connection per request and exhausts its ephemeral ports long
before the server is the bottleneck. Under a 125-connection load test the
client reported "no buffer space available" and ~7% of requests never got
sent at all.

A connection now survives its response unless the exchange says otherwise --
HTTP/1.0, `Connection: close`, an SSE stream or a WebSocket upgrade (both of
which own the connection), or a handler that threw. Bytes that arrived
behind the request just answered are the next, pipelined request, and are
dispatched without waiting for another read.

Keep-alive means a connection outlives the request that opened it, so a
server now tracks its live connections and stop() closes them. Without that
the loop stayed open after the listener was gone and a script that served
and then finished would hang instead of exiting.

Static throughput on this machine goes from 23.6k to 36.1k requests/sec,
with the failed requests gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sxn.serve's adapter built a `new URL` for each incoming request purely to
produce the absolute href a Request needs -- and a handler that wants the
parsed form builds its own URL anyway, so the parse was pure overhead. The
href is a concatenation of the Host header and the request path; the parse
only stays for the rare request line that is already absolute.

Static throughput, 125 connections on an M4:

  handler returning a plain object    98k -> 156k req/s
  handler returning a Response        75k -> 132k req/s
  the same plus its own URL parse     75k -> 107k req/s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JSON.parse and JSON.stringify both walked a string one character per
iteration: four comparisons per byte on the way in, a string_getc and a
string_buffer_putc per character on the way out. In a profile of a 1MB API
payload that scanning was the largest single cost in each direction.

Both now test eight bytes at once for the only bytes that matter -- a quote,
a backslash, a control character, and on the way in anything non-ASCII --
with the usual (x - 0x01..) & ~x zero-byte trick, and copy the run between
them in one go. Little-endian only; elsewhere the byte loop still runs.

Three allocations went with it:

- The parser accumulated every string into a StringBuffer that starts at 48
  characters and is then grown, copied and trimmed. A string with no escape
  and nothing above ASCII is now allocated once, at its final size.
- stringify allocated a quoted copy of every key and every string value,
  copied it into its output buffer and freed it. It writes through now.
- A plain integer is converted by the tokenizer rather than by strtod, which
  is locale-aware -- it takes a lock to find the decimal separator -- and
  rescans the digits. -0 still parses as -0, not 0.

On an M4, the new benchmark row (a 1MB payload parsed and written back out,
forty times) goes 165.4 -> 81.5 ms; Node is 26.9 and Bun 23.3. The same
document alone: parse 2.29 -> 1.88 ms, stringify 7.01 -> 2.26. Writing one
long string, where the scan is all there is, goes 2.52 -> 0.10 ms, ahead of
Node's 0.12.

tests/fixtures/json_edges.mjs checks the boundaries these fast paths
introduce -- an escape at offset 7, 8 and 9, non-ASCII and control bytes at a
word boundary, lone surrogates, every number form, the reviver, the replacer
and the inputs that must still be rejected. Its expected output is Node's
own, byte for byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three more things, after the word-at-a-time scanning:

A parsed string is no longer built until its use is known. An escape-free
ASCII string is carried as a slice of the input, so a property name becomes
an atom straight from those bytes rather than allocating a string, interning
it and freeing it again; only a value is allocated. On a document of 30000
small objects that is 90000 allocations that no longer happen: parse 8.56 ->
5.41 ms.

JS_ToCString, which is how the parser gets at its input, already returned an
ASCII 8-bit string's own bytes without copying -- but it decided that by
counting non-ASCII bytes one at a time across the whole string. It now
clears eight at a time and counts only from the first non-ASCII byte. Its
wide-string transcode, which is what an input pays when a single accent
makes the whole string 16-bit, copies four ASCII code points per iteration
instead of one. Both are on the path of every JS_ToCString caller in the
runtime, not just JSON.

On an M4: parsing a 1MB document 1.88 -> 1.34 ms against Node's 1.05, and
the benchmark row 81.5 -> 72 ms.

What is left is spread across property enumeration, the atom lookup behind
each property read, and one allocation per value, with no peak worth naming.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three costs, all per property or per value, none of them the escaping:

- An object's keys were collected by building a JavaScript array of key
  strings and then reading each property back out by string -- which hashes
  the string into an atom again on every lookup. An ordinary object with no
  replacer list now walks its own atoms: it reads by atom, and takes the key
  string from the atom rather than building one. For an object whose keys are
  all ordinary strings the atoms come straight out of its shape into a stack
  array, so there is no allocation per object at all.
- An integer, a boolean and null were serialized by asking JS_ToString for a
  string, copying it into the output and freeing it. Their bytes go in
  directly now. A JSON document is mostly those three.
- Every object was searched for a `toJSON` method, walking its prototype
  chain to find nothing. The last shape that had none is remembered, keyed on
  the runtime's property-location generation, which is already bumped
  wherever a property could move -- including installing `toJSON` on
  Object.prototype from a getter, halfway through a document.

On an M4: writing a 1MB document 2.26 -> 1.72 ms, a document of 30000 small
objects 8.50 -> 5.27, and the benchmark row 72 -> 56 ms.

json_edges.mjs gains the cases these paths have to keep getting right: a
toJSON installed mid-run on a shape already seen, an inherited toJSON,
non-enumerable and symbol and inherited keys, numeric keys (which sort ahead
of the rest, so they take the general path), a getter that deletes a later
key, array holes, and Date's own toJSON. Its expected output is still Node's,
byte for byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two places where the parser asked the generic property machinery to do what
it could do itself, on values nothing else can see yet:

- A property is added straight to the object being filled. JS_DefinePropertyValue
  goes through descriptor validation to reach the same add_property; the
  parser knows the object is a plain one it just created, so the only case
  worth checking is a repeated key, which JSON allows and where the last one
  wins.
- An array element is appended to the fresh fast array rather than defined as
  an indexed property on an object that might have been anything.

Parsing a 1MB document 1.34 -> 1.24 ms against Node's 1.06; 30000 small
objects 5.26 -> 4.48 against Node's 2.34; an array of 120000 numbers 4.23 ->
2.79. The benchmark row is 51.4 ms against Node's 28.2 and Bun's 25.3, from
165.4 where this started.

spec/PERFORMANCE.md records the whole pass and what is left: object-heavy
stringify, and js_dtoa for fractional numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… directly

An independent review of the last three commits found both of these, with a
reproduction for each; each now has a test whose expected output is Node's.

Every Proxy shares one shape, and a Proxy answers for `toJSON` from its own
trap. The memo of "this shape has no toJSON" therefore carried the first
proxy's answer to the next one: `JSON.stringify([p1, p2])` wrote p1's object
twice instead of calling p2's trap. The memo is now only taken for an
ordinary object whose prototype chain is ordinary too, checked once when the
entry is stored.

The specification takes an object's key list once, before any getter runs,
and decides enumerability then. This walked the keys and re-checked each one
as it went, so a getter that made another key non-enumerable could remove it
from the output -- and one that made a key enumerable could add it. Both
paths now filter when the list is taken, and the loop does not ask again.

That also makes the loop cheaper: with the key list settled and the shape
unchanged, a property's value is read from the slot recorded when the keys
were collected rather than looked up by atom. Writing 30000 small objects
5.02 -> 4.52 ms, a document of mixed objects 3.24 -> 2.92.

Also guard the wide-string ASCII copy in JS_ToCStringLen2 for endianness --
its mask is little-endian, and unlike the others it was not behind the check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With no gap argument both separators are the empty string, so each of the
three per-property concatenations copied nothing through a call. Writing
30000 small objects 4.52 -> 4.28 ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every object in a document of records repeats the same keys, and each one was
hashed into the atom table again. A four-entry cache in the parser state
holds the last few, keyed on the bytes themselves -- they are in the document
being parsed, which outlives the parse, so a hit is confirmed by comparing
them rather than trusting a hash.

Parsing a document of mixed objects 2.87 -> 2.70 ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fast paths in parse and stringify are now several, and json_edges.mjs
checks the boundaries one at a time. This walks 4000 seeded-random documents
-- nested objects and arrays, every number form, strings with escapes,
non-ASCII, lone surrogates and word-boundary cases, duplicate and empty and
__proto__ keys -- writes each one, parses it back, writes it again, and
hashes every string it produced. The expected hash is Node's for the same
seed, so a byte of divergence anywhere fails the test rather than waiting for
someone to think of the case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The circular-reference check kept the path from the root in a JavaScript
array, so every object serialized cost an Array#includes, a push and a pop,
each through the generic property machinery. The path is now an array of
object pointers in the stringify context: the check is a scan of a few
entries, and pushing is a store. The pointers are borrowed, which is sound
because an entry is on the path only while the frame holding a reference to
that object is running.

Writing 30000 small objects 4.29 -> 3.65 ms, a document of mixed objects
2.76 -> 2.39.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A second review found the memo still wrong in a case the proxy fix did not
cover. A shape records which properties an object has, not what they hold,
and storing a value into an existing slot moves nothing, so it does not bump
the property-location generation the memo is keyed on. Two objects of the
same shape -- `{x: 1, toJSON: undefined}` -- therefore shared an answer, and
assigning a real toJSON to the second one was ignored:

  JSON.stringify([a, b])   // gave [{"x":1},{"x":1}], not [{"x":1},"hijacked"]

The same holds one level up, for a prototype whose toJSON is replaced in
place between two children.

The memo is now stored only when no object on the chain has a `toJSON`
property at all -- checked once, when the entry is stored. Adding one
anywhere later does move a property, which does bump the generation, so a
stored entry stays honest.

Both cases are in json_edges.mjs, and both match Node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turning a double into its shortest round-tripping decimal was an exact
big-integer search, which is where a third of JSON.stringify's time went on a
document of fractional numbers. This adds Grisu3 (Loitsch, PLDI 2010) as a
fast path: it computes the digits with 64-bit arithmetic and a table of
cached powers of ten, and -- the part that makes it safe -- it proves the
result is the unique shortest form or declines. On a decline, and for every
other radix, format and exponent mode, the exact algorithm below runs exactly
as before.

The 64x64 multiply uses __int128 where the compiler has it and a portable
32x32 decomposition where it does not.

Stringifying 120000 random doubles: 22.8 -> 13.9 ms per pass.

Checked by differential test against Node over millions of values -- random
bit patterns, every integer from -1e6 to 1e6, powers of ten, subnormals, the
signed zeroes, infinities, NaN, the awkward decimals, and ULP windows around
1e21 and 2^53 -- with no divergence. The JSON fuzz corpus still hashes to
Node's value.

Written by Codex (gpt-5.4-codex) from a profile, reviewed and measured here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sxn.serve read `port` and nothing else. It accepted a hostname and ignored
it, binding loopback every time, so a server could not be reached from
another machine at all -- the option looked supported and was not. It now
binds what it is given: `hostname`, or `host` under Node's name for it, with
loopback still the default because a server nobody asked to expose should
not be reachable. IPv6 literals and "localhost" work; a name that needs DNS
is an error rather than a silent fall back to loopback.

`reusePort: true` binds with SO_REUSEPORT, so several processes can share
one port and the kernel spreads connections across them. That is how a
program uses more than one core on this runtime, which has no threads and no
cluster module -- and it is the difference the HTTP benchmarks have been
measuring, where Node's apps fork one worker per CPU and this one answers
everything from a single process. Linux, the BSDs, Solaris and AIX
distribute this way; macOS does not, and says so instead of handing one
process every connection.

The handle reports the hostname it bound, and its url names that host rather
than always 127.0.0.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copying every header into a Headers list costs about 1.4 microseconds a
request -- more than the Request object it belongs to -- and a handler that
routes on the method and the path never looks at them. The list is now built
on first read, from the raw object the native layer already produced.

new Request through the adapter: 1.84 -> 0.46 us when the headers go unread.
Static throughput over the loopback, 125 connections: about 85k requests a
second, from 78k.

tests/fixtures/serve_lazy_headers.mjs covers reading, missing keys, has(),
iteration, mutation, clone, a body alongside, and a handler that never
touches them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
UV_TCP_REUSEPORT arrived in libuv 1.49, and plenty of distributions ship
1.44 -- Ubuntu 23.10 among them, which is where this is going to be measured.
On Linux the socket is now made, given SO_REUSEADDR and SO_REUSEPORT, bound
and handed to libuv, which is the same thing the flag does. Everywhere else
an old libuv reports reusePort as unsupported, as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
process.pid was 0. A program that runs several copies of itself -- which is
how this runtime uses more than one core -- has nothing else to tell them
apart by in a log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every read allocated 64KB, filled it, copied it into the connection's buffer
and freed it. A 1MB request meant sixteen allocations, sixteen frees and a
megabyte of copying the kernel could have done into the right place. libuv
asks where to put the bytes, so it is told: the end of the buffer, with room
reserved geometrically as with any append.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every request built a JavaScript string of its whole body before the handler
ran, whether or not anything read it. For the 1MB JSON test that is a
megabyte copied and scanned per request, and then JSON.parse read the string
back out.

The bytes the server already has are handed over instead: when nothing else
is in the read buffer -- every request that is not pipelined -- the buffer
itself goes to JavaScript, so there is no copy at all. `req.json()` parses
straight from those bytes through a native entry point, with no string in
between. `raw.body` is still a string for node:http, but it is now a getter,
so it costs nothing unless something reads it.

On an M4, a 1MB POST: a handler that ignores the body 660 -> 5000 requests a
second, one that parses it 355 -> 600. The REST benchmark app 338 -> about
560.

A pipelined request cannot hand over the buffer, so it copies -- with the
trailing NUL the JSON parser expects, which is why the first of two
pipelined POSTs used to answer 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A reply is written in one go and wants to leave now, not when the kernel has
collected enough bytes to be worth a packet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Compiling failed on any file that imported another: the compile path built a
runtime and a context and never registered a module loader, so resolving
"./lib.sx" -- which happens while the module is compiled, not when it runs --
had nothing to resolve with. Every program of more than one file was
uncompilable, which is every real program.

The round-trip test now covers a two-file program, and spec/BYTECODE.md says
what bytecode is and is not for: it removes the parse, which happens once,
and does nothing for a running program's throughput.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
node:os was a set of guesses: the hostname was always "localhost", the
memory sizes 0, the CPU list empty, the release string blank -- and
networkInterfaces, availableParallelism, loadavg and userInfo did not exist.
A guess is worse than a gap here, because nothing calling it can tell the
difference. libuv knows all of it, so it answers now, and the shapes match
Node's: an interface entry carries address, netmask, family, mac, internal
and cidr, and a scopeid when it is IPv6.

node:fs gains stat, lstat, their sync forms and a Stats object with the
is*() methods and Date fields, from uv_fs_stat -- a missing file rejects
with ENOENT, as it does in Node. And createReadStream, which reads the file
and pushes it to a Readable: enough to serve a file, which is what asks for
it, and not a window onto a file too large to hold.

This came from a script that could not run: `import { networkInterfaces }
from "node:os"` failed outright, then `stat`, then `createReadStream`. It
serves its bundles now.

tests/fixtures/node_os_fs.mjs checks the lot against the real machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parsing a query string went through split(), a regexp for "+" and
decodeURIComponent for every part; stringifying built an array of JS strings
and joined it. Both are pure byte work with no state, which is what makes
them worth moving: js_qs_parse walks the string once and writes into the
result object directly, and js_qs_stringify writes into one growable buffer.

Node's own quirks come with it: a null-prototype result, the 1000-key limit
unless maxKeys says otherwise, a repeated key becoming an array, and a stray
"%" kept rather than throwing.

tests/fixtures/node_querystring.mjs holds 33 cases and the output Node gives
for them, so the test runs without Node and fails on any divergence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
path was half native: posix in C since phase 4, win32 still JavaScript --
and the last place in node_compat.js that walked a string with a regexp. It
is C now, and node_compat.js is 170 lines shorter.

While pinning it against Node, posix extname turned out to be wrong for a
name that is all dots: extname("..") answered "." where Node answers "".
Both halves use Node's own state machine for it now.

tests/fixtures/node_path.mjs runs 448 cases through every function of both
halves and compares them with what Node prints. posix matches exactly. win32
does not, on 50 Windows-only edge cases -- a drive-relative path with an
empty tail, a relative path across two roots, a segment that looks like a
device inside a relative path -- and those are listed in node_path.known, so
they are visible rather than silent and a new one fails the test. The
JavaScript this replaces missed a comparable set; the difference is that the
gap has now been measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
net.isIP was two regexps, a split and a per-part number check. Express calls
it for every request that carries X-Forwarded-For. It is uv_inet_pton now,
with the one thing that parser does not know about handled here: a zone
index (fe80::1%eth0) names an interface rather than part of the address, and
has to name something -- Node rejects a bare trailing "%", and so does this.

30 cases against Node's own answers, identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HMAC was built here: a block-size table, a key padded into two buffers, a
XOR loop over each, and three digest calls with a Uint8Array allocated per
update. OpenSSL has one function for it, and it was already linked in for
the digests themselves.

timingSafeEqual mattered more. It was a JavaScript loop over two arrays,
which cannot promise to take the same time whether the bytes match or not --
which is the entire point of the function. It is CRYPTO_memcmp now.

17 cases against Node's own digests, identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nanofuxion and others added 29 commits August 31, 2026 18:59
process.nextTick copied its `arguments` into an array with
Array.prototype.slice and built a closure over it, per tick. The C
version carries up to three arguments in the closure itself and keeps
the rest in an array: 0.620us became 0.143us.

Getting there found a sharp edge worth writing down:
JS_NewCFunctionData stores its magic unsigned, so the -1 I first used
to mean "the arguments are in an array" came back as 65535 and the
call read 65535 arguments off a four-slot array. It is 4 now.

tests/fixtures/node_next_tick.mjs runs every arity from none to seven,
checks `this` is undefined inside the callback, that the ticks run in
order, and that a non-function is refused. Node passes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was the largest piece of real logic left in node_compat.js and the
widest gap against Node: 5.26us for a small object where Node spends
1.56. It built an options object per level, a mapped array per
container and a joined string per level. js_inspect walks the value
into one buffer: 2.26us.

Before deleting the JavaScript, both were run side by side over 39
values -- every primitive, functions, dates, regexps, maps, sets, typed
arrays of two kinds, nested containers, a cycle, a shared subtree, the
depth limit at four settings and quoteStrings -- and they printed
identically.

Two things the C one prints better. An invalid date used to throw out
of toISOString at whoever tried to print it; it says "Invalid Date".
And an error now carries the "Error: message" line that this engine's
own stack leaves off, which is what Node shows.

tests/fixtures/node_inspect.mjs pins all of it, including the key
quoting and that numeric keys sort first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A table of all seventeen sections: how many lines each is, what is
native underneath it, and what the JavaScript around it still does.

The short version is that no section still contains a loop over bytes
or characters. What is left is dispatch on argument types, class
shapes, and event plumbing -- and the two experiments that measured
those from C (a Readable's ten field stores at 0.40us against the
interpreter's 0.34, and a pushed array reparented rather than wrapped)
are recorded above it with their numbers, one rejected on speed and one
on behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
util.promisify spread its arguments, built a Promise around an executor
closure, and built a callback closure inside that -- per call. The C
version makes the promise directly and the callback carries its two
resolving functions: 0.62us became 0.44us, against Node's 0.08us.

Two behaviours now match Node rather than what was here. A callback
reporting more than one value resolves with the first; the JavaScript
resolved with an array of them. And a function that throws
synchronously produces a rejection, because Node calls it inside the
promise.

node_builtins.mjs gains eight promisify cases and prints identically
under Node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0.29us, not the 0.44us measured before the synchronous-throw path was
rewritten. Three runs agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Readable#pipe built a closure per event -- data, drain, end, error --
for every pipe. They are four C functions sharing the source and the
destination now, with the same record kept for unpipe.

The number is small: 1.95us to 1.83us. This one is kept for the four
allocations it stops making rather than for the six percent. For
scale, Node's pipe is 7.21us, because it does a great deal more
bookkeeping than this one.

node_stream.mjs gains the data path, the end: false option, unpipe, and
the destination being returned. Error forwarding is left out of that
file deliberately: an error on the source reaches the destination here,
where Node destroys the pipe instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Buffer.from ran the Uint8Array subclass constructor for every call.
Copying a view in C instead is 0.285us to 0.210us.

The same change for a plain array made it worse: reading elements one
at a time from C measured 1.185us against the engine's own 0.375us,
which fills the array without leaving the interpreter. So arrays keep
the constructor, and node_compat.js says why next to the line.

A Float64Array still goes the array way, because Node truncates each
element to a byte there rather than taking the raw bytes.

node_buffer_units.mjs gains six cases: the copy not sharing with its
source, a slice of a view, a float array, an empty view, an array with
values Node truncates, and an array-like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
util.inherits went through Object.defineProperty and
Object.setPrototypeOf; the same two property operations from C are
0.250us to 0.060us, which is also faster than Node's 0.150us.

node_builtins.mjs checks it still refuses anything that is not a pair
of constructors and that super_ stays writable. Node prints the file
identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
util.callbackify built two closures per call, one per half of the
promise. They are C functions carrying the callback now: 1.32us to
0.82us, against Node's 0.38us.

It also picked up Node's handling of a falsy rejection: an Error
reading "Promise was rejected with falsy value" with the original on
.reason, where this invented a bare "rejected" and lost what was
thrown.

Measured and left alone: the stream async iterator, at 0.38us an item
against Node's 0.12us. What it spends is a promise and a result object
per item, which is the iteration protocol, not something C can skip.

node_builtins.mjs gains five callbackify cases and prints identically
under Node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A second table in spec/NODE.md, covering the pieces the section table
above still calls JavaScript: util.types, net's isIP wrappers,
url.format and url.parse, createRequire, zlib.gzipSync, unpipe, the
async iterator, the two stream constructors, res.writeHead and
Buffer.from of an array.

Each has its cost next to it and the reason it is not C: an instanceof,
a wrapper over something already native, the engine's own URL, zlib
itself, the iteration protocol, or a measurement showing C slower. The
slowest of them is 2.65 microseconds and that is zlib compressing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things the last sweep had left at the bottom of the table.

zlib ran deflateInit2 and deflateEnd around every call, which allocate
and free the window and the hash tables -- a quarter of a megabyte at
these settings -- to compress 240 bytes. zlib's own answer is
deflateReset, which keeps the state and the settings, so one stream per
direction is kept and reset; a change of window bits or level drops it
and builds a fresh one, and a stream that failed mid-run is dropped
rather than reused. gzipSync of 240 bytes: 3.45us to 2.50us.
deflateSync: 2.85 to 2.25. gunzipSync: 3.35 to 2.90. All three are now
faster than Node's 3.40, 3.20 and 4.05.

zlib-ng and libdeflate were considered and rejected. Both are a new
dependency, libdeflate has no streaming API at all, and this is already
ahead of Node without either.

off() rebuilt the entire listener array on every removal. It now finds
the first match and closes the list up in place. emit walks that same
array and Node emits to the listeners that existed when it started, so
emit counts its depth and off still copies while an emit is running --
without that, a listener removing another mid-emit skipped it, which
emit_fusion.mjs caught.

Unpiping 20000 sources from one destination: 368us to 58us a call,
against Node's 168us. Removing from a list of 200 listeners: 4.87us to
1.24us. An ordinary unpipe is unchanged at 0.70us.

tests/fixtures/node_zlib_reuse.mjs compresses the same bytes repeatedly
across all three containers, alternating levels, six sizes including
empty, and after a failed inflate. emit_fusion.mjs gains four cases for
removing a duplicate listener, keeping the order, and removing a
duplicate from inside an emit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zlib's kept stream and the emit-depth counter are both static. That is
safe because zlib is reachable from JavaScript alone and this runtime
runs JavaScript on one thread -- the only threadpool work in the tree
is napi.c's, which never reaches either. A worker-thread API would have
to make both per-runtime, and the comments now say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps, closed against the same list the WinterTC proposal publishes.

The web surface was 45 of the names it asks for. It is now all of them
except WebAssembly, which QuickJS has no engine for and which is left
undefined so feature detection keeps working. New here: URLPattern, the
compression streams over the zlib already linked in, BYOB reads, the
controller and reader classes the spec names (they existed, unexported,
so instanceof said no), ErrorEvent, PromiseRejectionEvent, Performance,
reportError and self.

Errors that reach the top now go somewhere first. The global object is an
event target, and onerror, onunhandledrejection and onrejectionhandled
see what happens before it is printed. A rejection is reported once every
job that could still have handled it has run; with no handler registered
nothing changes, which is what keeps existing programs quiet.

The node: builtins were 20 base names. They are 37, which is what Node
ships. Four of the additions are real work in C: uv_spawn behind
child_process, uv_getaddrinfo behind dns, a uv_udp_t behind dgram, and
fs's flag numbers read from this platform's headers rather than written
down (O_CREAT is 0x200 here and 0x40 on Linux). zlib grew a streaming
form -- one z_stream per object, rather than the one-shot the sync calls
share -- which is what the compression streams are built on.
builtinModules is now read off the same table require uses; the list in
JavaScript had fallen behind it by several modules that did resolve.

The rest are honest about their limits rather than absent, and spec/NODE.md
has the table: child_process runs a child to completion on a loop of its
own, so its async forms deliver output at the end rather than as it comes;
dns has no resolver but the system one; vm has one realm; async_hooks has
a real AsyncLocalStorage and no async context tracking under it; tls,
http2, worker_threads and cluster answer the question a library asks and
throw where a second thread or a TLS socket would be required.

WinterCG is WinterTC throughout, including benchmarks/wintertc.

95 tests pass, up from 93: the two new fixtures exercise rather than probe
-- a real child process, a real DNS answer, a real UDP round trip, gzip
through a stream and back. The debug build fails only sxn-example-server,
which it did before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things the Linux box found that the Mac did not.

A loop closed while a handle is still registered leaves libuv's own
child-process bookkeeping behind it, and the next uv_spawn walks into what
is left. On Linux that is a segfault: node_new_builtins crashed on the
fourth or fifth child every run. Everything still open is now closed, the
loop is run until those closes complete, and only then is it closed.

The unhandled-rejection report asked the global object for its JavaScript
half on every turn of the event loop, which is every batch of I/O a server
handles. It now asks an int that the tracker sets, so a program with no
rejection in flight pays one comparison.

95 tests pass on both machines, five runs each of the fixture that crashed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Forty-four modules were registered at startup, each a JSModuleDef and an
atom per export name, whether or not the program imported one. They are
registered by the loader now, on the specifier it was asked for. require()
never needed them: it reads the global table.

The eighteen builtins added yesterday are built on first use too. Their
objects, prototypes and closures used to be constructed on every launch;
almost no program requires node:dgram.

Cold start on the Mac, minimum of 150 interleaved launches: 7.15 -> 6.97 ms
against 6.83 before the two changes. What is left of that gap is the web
surface in bootstrap.js -- URLPattern, the compression streams and the
event-handler properties are built eagerly because a page-shaped global has
to be there before the program runs.

95 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both resolved through require() and threw for `import`: the two paths are
separate -- the loader registers a module, require reads a table -- and
nothing checked that a name answers to both. The fixture now imports every
one of the forty-four specifiers as well as requiring the thirty-seven base
names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Mac table is a fresh run of the whole harness, with the JSON row added
to it -- a row sxn loses, which is the point of publishing the table at all.
Seven of nine now, and the two it does not take are EventEmitter and JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same run as the README's table. The JSON row is new here too, and it is
Bun's -- the page says so rather than leaving the row out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Where the 0.2 ms went, and why the rest of the gap is the web surface
rather than something left undone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four passes of twenty launches each, taken after the lazy-registration
change and with nothing else running: cold start 7.5 ms, the real-world
task 8.4 ms. The earlier pair was measured while the Linux box was being
driven over ssh from this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Linux box was four versions behind, which the note had to explain away.
It runs v23.11.1 for this pass, and Bun 1.2.21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same nine rows as the Mac table, same seven-of-nine result, and the two
that are not sxn's are the same two. Against Node 23 rather than Node 18,
which is what this machine was measured against before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng them

&mut on a binding that was not declared `let mut` is now a parse error
(SX2003) rather than silently aliasing a value nothing promised was
mutable -- js_parse_unary resolves the borrowed identifier against the
current function's lexical scope and its top-level lexicals, and leaves
anything it cannot resolve alone rather than guessing.

Declared scalar types are recorded on the binding instead of being erased
with the rest of the annotation syntax, and codegen spends them in two
places: `safe let mut n: i32` now wraps consistently regardless of the
right-hand side's shape (previously a constant and a local operand took
different peephole paths and disagreed), and a call to a small function
whose parameters and return are all declared scalars is spliced into its
caller at the pass-2 peephole. Measured on an M4, the inlined call goes
from 16.3 ns to 5.3, matching a hand-written equivalent.
…steners

Nothing in this codebase ever called JS_RunGC. Collection only ran from an
allocation trigger that ratchets its own threshold to 1.5x the last peak, so
a burst of cyclic garbage built and dropped by a otherwise-idle process was
never reclaimed -- reproduced with 200k self-referential closures leaving
229 MB that 96 MB of later churn did not collect once. The event loop now
sweeps when the previous uv_run blocked (nothing was in flight), it has been
holding more than a floor (32 MB by default), and it has not swept in the
last half second, resetting the threshold afterward so the fix does not
just move the ratchet. --no-idle-gc turns it off; Sxn.gc() is the explicit,
unconditional ask. Sxn.memoryUsage() gains an rss field alongside the
existing allocator accounting, since the two diverge once the system
allocator starts holding freed pages.

EventEmitter.defaultMaxListeners existed and was read by nothing. js_ee_on
now counts registrations per event and emits Node's own
MaxListenersExceededWarning, once per emitter and event, when the count
exceeds the limit -- the standard signal that handlers are being added and
never removed. setMaxListeners/getMaxListeners let a program raise or
silence it.
The site was the repo's specs rendered one-to-one, so a newcomer's first
documentation page was an engineering ledger or a lab notebook -- both
genuinely valuable, neither answering "why would I use this" or "how do I
start". Adds a guide layer (Why sxn, Install, Quick start, Examples, plus
task-shaped guides for the HTTP server, node: packages, types, ownership
and benchmarks) in front of the existing specs, which move to Reference and
Project sections rather than being cut.

Every page now also serves as plain markdown at docs/<slug>.md, and a page
menu (Copy markdown, View as Markdown, Open in ChatGPT/Claude, Edit on
GitHub) surfaces it, matching the affordance Bun and Lynx's docs sites
offer. llms.txt links the markdown twins instead of the HTML pages.

The landing page leads with what sxn is and the speed numbers instead of
the language pitch, and gains the sxn/Node/Bun feature table and a
benchmark table pulled from README.md via a new include-section directive
in the generator, so the two can't disagree about a measured number.

build-docs.py: two include mechanisms extended/added --
expand_section_includes gets `table` and `body` filters so a page can pull
in just a table or just a section's prose without also dragging in the
trailing commentary written for a reader of the whole source document.
@nanofuxion
nanofuxion merged commit b48fc95 into main Sep 1, 2026
0 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant