Skip to content

ZIPP v0.0.29

Latest

Choose a tag to compare

@github-actions github-actions released this 23 Oct 02:13
· 1 commit to main since this release

ZIPP 0.0.29

A release for embedders and for large scripts. An embedded script's error
positions are now counted from its own text, and a syntax error says where
it is. A sandbox's host requests have an owner, an abort signal and a
bound, and a permitted redirect is checked before it is followed. A large
script keeps one copy of its source, and zipp js keeps its compiled code
between runs. Most of the fixes come from an audit of 22 October.

  • Positions in embedded scripts are the script's own. The browser
    engine compiled a host's script behind its preamble and counted lines
    from the top of the joined text, so an app's line N showed as line N+154.
    Lines and columns now count from the first character the host passed, as
    zipp js and Node count a file. setScriptOrigin(name, lineOffset, columnOffset) names the script and moves its positions, as Node's
    vm.Script options do, and the preamble's own frames read
    zipp:preamble.

  • Syntax errors give their position: SyntaxError: unexpected token ';' followed by at app.js:2:9, with the line and column Node reports.

  • Security: a permitted redirect's destination is checked before it is
    requested.
    With permissions.network.redirects: true, the embed kit
    used to follow a redirect and check the final URL afterwards, so an origin
    the allowlist does not name had already received the request. Each
    redirect is now checked before the next request is made.

  • A sandbox's host requests are bounded and cancellable. At most 64 run
    at once. Each request gets an AbortSignal that fires when the sandbox
    gives it up or closes, and close() settles every run still waiting on
    one. Loader 1.3.0, host SDK 1.4.0.

  • Large scripts use less memory and start sooner. Each function's
    source text is now a range of one shared copy of its script, so
    TypeScript's peak working set falls from 349 to 310 MiB. Run again,
    TypeScript's 10 MB bundle loads its compiled code from zipp js's new
    cache. It then finishes in 0.83 s instead of 1.14 s, in 191 MiB.

  • A class evaluated more than once runs its own super and its own
    name.
    In 0.0.28, a mixin applied to two bases ran the second class's
    super.m() for both.

  • Inherited reads, private fields and x == null are faster in the
    interpreter.
    Relative to 0.0.28's time:

    • an inherited-read loop takes 0.83x;
    • class features take 0.89x;
    • polymorphic objects take 0.90x.

    The 78 benchmark programs take 0.99x 0.0.28's time interpreted and 0.98x
    with the JIT.

Downloads

File What it is
zipp-0.0.29-windows-x86_64-portable.exe The Windows command line; run it from anywhere.
zipp-0.0.29-windows-x86_64-installer.msi The same binary, installed for the current user into %LOCALAPPDATA%\Programs\ZIPP and added to PATH; no administrator rights. Uninstall from Settings > Apps.
zipp-0.0.29-linux-x86_64 The Linux command line, x86-64 (glibc 2.35 or later). chmod +x it after downloading.
zipp-0.0.29-linux-arm64 The same for 64-bit Arm.
zipp-0.0.29-linux-armv7 The same for 32-bit Arm (hard float); interpreter only.
zipp-0.0.29-javascript.wasm The JavaScript engine for browsers, Node, Deno and Bun.
zipp-0.0.29-javascript-lite.wasm The JavaScript core without Intl and Temporal, built for size.
zipp-0.0.29-javascript-python.wasm JavaScript and Python, torch built in.
zipp-0.0.29-javascript-python-base.wasm JavaScript and Python without torch.
zipp-0.0.29-torch-package.wasm The torch package for the -python-base engine.
zipp-0.0.29-<engine>.wasm.br Each WebAssembly module compressed with Brotli (quality 11), about a quarter of its size. Serve it with Content-Encoding: br and Content-Type: application/wasm, or let zipp.mjs load it; the plain .wasm is the default.
zipp-0.0.29-wasm-embed-kit.zip zipp.mjs (the loader and sandbox, version 1.3.0) and its types, the host SDK (1.4.0), the engine's TypeScript declarations, the browser GPU adapter, docs and three examples.
SHA256SUMS Checksums of every file.

Changes in behaviour

  • zipp js keeps a large script's compiled code between runs. For a
    script of 32 KiB or more, it stores the compiled program in the folder
    zipp py already uses for Python's: %LOCALAPPDATA%\zipp\cache on
    Windows, $XDG_CACHE_HOME/zipp (or ~/.cache/zipp) on Linux. Later runs
    load the program from there instead of parsing and compiling it again.
    Entries are checksummed and keyed on the source, the grammar goal and the
    ZIPP_* settings. Each executable has a folder of its own, holding at
    most 64 MB of the most recently used entries. Only the current
    executable's folder and the three most recently used others are kept, so
    the cache holds at most 256 MB. ZIPP_CACHE_DIR
    names another folder; set to empty, it turns the cache off. Smaller
    scripts, modules, embedders and sandboxes never use it.
  • The browser engine's preambleLines is 0. Positions need no
    correction any more, so a host that subtracts preambleLines stays
    right.
  • class { } and class extends B { } as statements are syntax
    errors
    , as in Node. A class declaration needs a name; only
    export default class { } may leave it out.
  • Python's x in b"..." raises ValueError for an int outside 0..255,
    as CPython does. It used to answer False.
  • With permissions.network.redirects: true, at most 5 redirects are
    followed. In a browser, whose manual redirects hide their destination, a
    redirect fails the request.

Fixes

  • A class definition evaluated more than once ran the newest
    evaluation's code.
    Examples are a class expression in a function called
    twice, or a mixin applied to two bases. super.m(), super.x, super in
    arrows, field initializers and static methods, a method's use of its own
    class's name, and computed field keys all resolved to the latest
    evaluation. const M = B => class extends B { m() { return super.m(); } }
    applied to A and then B answered B's method for both. With a private
    field in the parent, the first class's method threw "Cannot read private
    member". Each now resolves against the evaluation it belongs to, with the
    JIT and without.

  • Python's / on two large ints rounded twice. It also flushed small
    results to zero: 1 / (1 << 1020) was 0.0, and
    ((1 << 80) + (1 << 27) + 1) / (1 << 80) was 1.0. It is now CPython's
    algorithm, rounded once, half to even. It agrees with CPython 3.13.14 on
    200,000 boundary cases; the old code differed on 26,649.

  • Python's needle in hay on two ASCII strings took quadratic time for
    a needle that almost matches everywhere: 263 ms at 256K characters, in
    one native call that was charged for the haystack alone. It now uses the
    engine's linear substring search and is charged for both lengths.

  • Python's in on a range and on bytes:

    • x in range(...) asks each member's equality for anything that is not
      an int, as CPython does, so a user __eq__ or an int subclass is
      answered correctly.
    • bytes gained __contains__; bytearray inherits it.
    • b"bc" in bytearray(b"abc") is True.
  • TextDecoder.decode continued from stale state when an
    options.stream getter decoded with the same decoder. A fatal error also
    forgot that a stream's BOM had been seen, so a later U+FEFF was stripped.

  • A torch kernel call over a small view far into its buffer allocated the
    whole span below the view
    : 16 MiB for a 64-byte view at 16 MiB. Only
    the bytes the request carries are decoded now.

  • zippInstanceUsage()'s retained* totals were described as code that
    outlives Engine.dispose().
    That code has been freed with its engine
    since #53. The totals are now named disposedDynamicFunctions,
    disposedDynamicClasses, disposedDynamicFunctionBytes and
    disposedDynamicClassBytes, with the old names kept as aliases.
    enginesLive is new.

  • A syntax error in an embedding preamble was reported as the guest's
    line 1.
    It now gives the preamble's own line, and a preamble function
    that calls nothing is named for the preamble.

  • A heap ceiling convicted a script with well under the ceiling
    alive.
    This was in 0.0.28 too. Two things caused it:

    • after a minor collection, the ceiling was checked at once, without
      the major collection that frees old garbage;
    • near the ceiling, the heap's slot tables doubled to hold garbage, and
      they count against the ceiling for the rest of the run.

    Phases of 30,000 objects, about 7 MB alive at a time, were convicted at
    20 and 24 MiB. A VM called again and again with such a phase was
    convicted by its third to fifth call. Near its ceiling, the heap now
    grows its tables by a step and runs a major collection first, and only
    the check after a major can convict. A heap with no ceiling, like the
    zipp command's, is unaffected.

Embedding

New in the zipp-vm crate:

  • embed::ScriptOrigin { name, line_offset, column_offset }, Node's
    vm.Script filename, lineOffset and columnOffset. It is taken by
    compile_script_with_origin and
    compile_script_with_preamble_and_origin.
  • ScriptState::eval_wrapped_in_context and
    eval_wrapped_in_context_rich take the host's expression range inside
    its wrapper. Positions then count from the expression, and the wrapper's
    frames are left out.
  • zipp_vm::set_code_cache(dir) names the folder for compiled large
    scripts, as the zipp command does.

New in the browser engine and the embed kit:

  • setScriptOrigin(name, lineOffset, columnOffset), before initScript.
  • A host function runs with this.signal. A fetch combines that signal
    with the time limit, and fails as cancelled when it aborts. An isolated
    sandbox gives a request up across its Worker boundary when its own time
    limit passes.
  • At most 64 host requests run at once; the rest wait in order. A request
    that ignores its signal keeps its place until it returns, and its late
    answer is discarded. usage() reports hostRequestsRunning and
    hostRequestsWaiting.
  • close() settles every run or call still waiting on host requests
    (closed), aborts the running ones and starts none of the waiting ones.
  • The host SDK's host.signal aborts, with deathCause, when its
    generation dies.
  • Redirect rules:
    • each Location must be an http(s) URL the allowlist names;
    • one time limit covers the request and every hop;
    • a 303 (or a 301 or 302 after a POST) continues as a GET without its
      body, and the new method must be permitted;
    • authorization is not sent to another origin.

Performance

Measured on 23 October 2026, native Windows x86-64 (Ryzen 9 9950X3D):

  • the published 0.0.28 Windows binary;
  • a plain release build of this release's code, as the published Windows
    binary is.

Both run with address-space randomization turned off, so image placement
does not move the numbers. The figures are medians of interleaved runs.

Bundled applications. Each library is bundled into one script that runs
a fixed workload. Each figure is the median of 7 runs of zipp js app.js
in its default mode, and the time includes compiling the bundle. The 0.0.28
and 0.0.29 columns have the code cache off, as on a first run. The warm
column has the cache filled.

Program 0.0.28 0.0.29 0.0.29, cache warm Bellard's QuickJS
Babel: @babel/standalone transforms about 20 KB of modern JSX twice 5.04 s 5.00 s 4.79 s 3.72 s
TypeScript: transpileModule on about 40 KB of classes, generics and enums 1.16 s 1.14 s 0.83 s 1.03 s
Prettier: formats JavaScript, TypeScript and Markdown 0.56 s 0.56 s 0.46 s 0.44 s
marked, lodash, acorn, terser, three.js, immer, RxJS and date-fns 0.82 s 0.82 s 0.75 s 1.83 s
Vue server rendering 93 ms 94 ms 79 ms 85 ms
Preact server rendering 76 ms 74 ms 73 ms 72 ms
React server rendering 76 ms 78 ms 69 ms fails (no TextEncoder)

Peak working set, 0.0.28 to 0.0.29 (cache warm in parentheses):

Program 0.0.28 0.0.29 (warm)
TypeScript 349 MiB 310 MiB (191 MiB)
Babel 250 MiB 240 MiB (173 MiB)
The eight libraries 212 MiB 188 MiB (175 MiB)
Prettier 158 MiB 159 MiB (117 MiB)

QuickJS holds 34 to 118 MiB on the same four.

The 78 benchmark programs (median of 3 runs each; every output
identical):

  • Interpreted (ZIPP_NOJIT=1, which leaves the regex JIT on, as in
    0.0.28's notes): 0.990x 0.0.28's time, faster on 47.
    The largest gains are inherited reads (0.83x), class features (0.89x),
    polymorphic objects (0.90x), a local whose type keeps changing (0.92x), a
    small reconciler (0.93x) and Float64Array writes (0.93x).
  • Slower interpreted, each confirmed over 9 runs: an eight-way switch
    loop (1.09x), arguments.length (1.05x) and a two-argument call loop
    (1.04x). The rest are within 4%.
  • With the JIT: 0.984x 0.0.28's time, faster on 48 and within 5% on
    the rest.
  • Against Bellard's QuickJS in the same runs, in that lane: the
    interpreter takes 0.864x QuickJS's time and is faster on 45 of the 78
    programs. 0.0.28 took 0.873x and was faster on 40.

What changed in the interpreter:

  • An inline-cache site's first way answers inherited reads and private
    fields as part of its own-property hit. An object's explicit prototype is
    read from a dense copy beside the prototype table.
  • The private-name slow paths are out of the dispatch loop. Private-name
    records are pruned at a minor collection only once they have doubled.
  • x == null and x != null are decided without reading x's heap slot.
    A constant that is not a string is answered at its load.
  • A site that stopped caching fills again on every 64th slow visit, which
    helps code that makes a new class per call. ZIPP_NO_IC_PROBATION=1 turns
    it off.

The interpreter figures in
docs/PERFORMANCE.md
now come from a strict lane, with the regex JIT off as well: 0.683x
Bellard's QuickJS over 30 rows. The earlier lane left ZIPP's regex JIT on.
The README's and that page's figures describe the profile-guided 0.0.28
capture of 21 and 22 October 2026.

Publishing

As for earlier releases, the engines the site serves are refreshed AFTER this
release exists, in one commit that:

  1. sets zippEngineRelease in landing/package.json to 0.0.29;
  2. refreshes landing/public/playground-runtime and landing/public/wasm from
    zipp-0.0.29-javascript-python.wasm and zipp-0.0.29-javascript.wasm
    (node crates/zipp-wasm/embed-kit/package.mjs extract, then
    sync-playground.mjs --refresh-engine), SHA256SUMS-verified;
  3. syncs the sandbox playground (node landing/scripts/sync-sandbox.mjs with
    the release's files, which brings the site's zipp.mjs to loader 1.3.0)
    and rewrites the site's Brotli copies (npm run compress:engines);
  4. updates the version, size and SHA-256 that landing/src/App.tsx and
    landing/README.md state about the bundled engine.

Full Changelog: v0.0.28...v0.0.29