Prepare 0.0.2 six-platform release - #2
Merged
Merged
Conversation
…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>
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.