Skip to content

Porffor

Generated from docs/engines/porffor.md when the site was built at 7aa23e2.

Porffor (github.com/CanadaHonk/porffor) compiles JS ahead of time to C. niso’s apps run on it today: the counter, scroll and controls examples and the Svelte fixture compile, run, and produce the same DOM and render stream as on QuickJS, and 39 of the 43 JS binding tests pass. Two fail because Porffor’s WeakRef holds its target strongly and its FinalizationRegistry never calls back (the lifetime tests); that is what blocks shipping, together with the ones listed under What blocks shipping. The other two, the WebSocket tests, cannot run: their scripts carry a server port chosen at run time, and Porffor runs only scripts it was built with. The device apps run on it too: radio on Android and the iOS simulator, and ihatemyphone’s panel and setup pages headless (see Device apps).

Tested with Porffor alpha 22 (a081ebd, 2026-11-05, the newest release, from curl -fsSL https://porffor.dev/install.sh | sh) on Linux x86_64 with clang 18.

Porffor has no embedding API and no eval at runtime, so the engine is part of the program Porffor compiles:

  • crates/niso-porffor/build.rs writes one JS program: bindings/js/porffor/prelude.js, the glue with its generated part, and every script the program may run, each a case of eval("<source>"). Porffor compiles a literal eval ahead of time, completion value included. porf c turns the program into one C file, which cc compiles with -Dmain=qe_porf_main.
  • The prelude includes bindings/js/porffor/qe_porffor.c as file-scope C (Porffor.c), so the engine reaches the runtime’s static functions: values (jsval), the allocator, arrays and strings, porf_call_dynamic to call a JS function from C, and the setjmp/longjmp exceptions (porf_try_depth, porf_try_ensure, porf_exception).
  • JS calls C through Porffor.c statements in prelude functions, which name JS locals by their bare names (${x} interpolation renders undefined). Every host function is a JS shim (...args) => __niso_host_call(i, args) over the binding core’s host table. A host function that throws makes the glue’s error and porf_throws it after the C frame has cleaned up.
  • C calls JS (hooks, the script table, ToNumber and ToString) with porf_call_dynamic inside a try frame, so no JS exception unwinds through the DOM’s Rust frames.
  • The GC scans the C stack conservatively up to porf_c_stack_top. Porffor’s main sets it for the top level only, so each entry from C sets it to a frame above the one that holds JS values.
  • Microtasks are Porffor’s promise jobs (p_builtins___Porffor_promise_runJobs); qe_gc runs porf_gc_collect. The glue now defines queueMicrotask itself (Porffor’s runtime only provides it when the outer program names it).
  • Porffor treats a program naming fetch, Headers or Response, even a binding of its own, as using its own fetch, and compiles in its network runtime (libuv, OpenSSL, ada and uWebSockets), which niso does not link (porffor-own-fetch-pulls-network-runtime). The glue binds its fetch, Headers and Response under other names and defines the globals from them. A script that names them itself would get Porffor’s fetch rather than niso’s, so build.rs refuses a program whose C includes that runtime. URL and URLSearchParams pull in ada the same way, and a top-level class of either name is replaced by Porffor’s own (porffor-own-url-class-replaced), so the generator declares niso’s as NisoURL and NisoURLSearchParams and X gives them their interface names. A top-level class MessageChannel pulls in libuv the same way (porffor-own-messagechannel-class-replaced), so niso’s is NisoMessageChannel.
  • Module scripts do not run: a module loads its graph at run time, so qe_eval_module reports one as an error.
  • Bytes cross as arrays of numbers: qe_bytes copies a buffer or view the prelude reads into one, and qe_ret_bytes and qe_push_bytes have the prelude make an ArrayBuffer from one.
  • qe_eval finds a script by niso_js_script_hash (FNV-1a of its bytes) and fails for a script the program was not built with.
  • The runtime is one global heap, so a process runs one engine at a time: after niso_js_free, a new engine starts the glue again on the same heap (__niso_start), but a second niso_js_new while one is alive fails with “niso: Porffor runs one engine at a time”.
  • Porffor’s runtime defines its own navigator and console, and a program’s own global of either name is ignored (porffor-navigator-console-global-ignored), so the glue copies niso’s members onto Porffor’s objects (__niso_onto).

The Rust side is niso-quickjs’s own Js API and niso-run source, built against the Porffor engine. Without NISO_PORFFOR_SCRIPTS the crate builds without an engine, so workspace builds do not need Porffor.

Terminal window
# niso-run on Porffor, built with the app it runs
NISO_PORFFOR_SCRIPTS=examples/dist/counter.js cargo build --release -p niso-porffor
target/release/niso-run-porffor --dump --tap 60,140 examples/dist/counter.js
# niso-quickjs's dom, lifetime and streaming tests on Porffor: record the
# scripts they evaluate, build them in, and run each test in its own process
mkdir -p target/porffor/scripts
NISO_JS_RECORD=$PWD/target/porffor/scripts cargo test --release -p niso-quickjs --test dom --test lifetime --test streaming
NISO_PORFFOR_SCRIPTS=target/porffor/scripts cargo test --release -p niso-porffor --no-run
target/release/deps/dom-* --exact class_list_replace
# everything below
node tests/porffor/run.mjs

node tests/porffor/run.mjs, on a 4-core cloud machine (x86_64, 16 GB), 2026-11-08. Each app is built into its own niso-run-porffor and run against niso-run (QuickJS-ng) with --time --repeat 50 --render FILE --dump and the taps below. Startup is from process start to the first rendered frame. It splits into engine init (DOM, fonts, engine and glue, mostly not JS), scripts (the app and its timers) and the first frame. Frame is one tap, the tasks it makes due, and a rendered frame. The render stream is compared as bytes, and with node handles replaced by tree paths (handle values depend on when the GC lets the DOM free nodes). “Engine + app code” is the text and data of QuickJS plus the binding core, against Porffor’s compiled program (runtime, glue and app) plus the binding core. The QuickJS binary still loads the app’s JS source at runtime (163 to 175 KB for these apps).

Every one of the 21 runs of an app must exit cleanly and leave the same DOM and render stream for “runs” to say yes.

Taps: counter 60,140 (the button); scroll 100,300 (a row, no listener); controls 88,289 (Disconnect); svelte none (it clicks its own button).

app compiles runs DOM matches render stream matches compile (porf + cc) engine + app code, Porffor / QuickJS stripped binary, Porffor / QuickJS startup ms, Porffor / QuickJS engine init ms, Porffor / QuickJS scripts ms, Porffor / QuickJS frame ms, Porffor / QuickJS max RSS MB, Porffor / QuickJS nodes alive after GC, Porffor / QuickJS
baseline yes yes yes yes 2.5 s + 66.3 s 4.10 MB / 1.69 MB 26.92 MB / 24.55 MB 47.16 / 63.91 46.62 / 63.42 0.08 / 0.07 - / - 21.2 / 21.5 5 / 5
counter yes yes yes yes 2.7 s + 92.4 s 5.25 MB / 1.69 MB 28.06 MB / 24.55 MB 43.20 / 74.82 39.24 / 65.03 1.50 / 7.58 0.69 / 0.75 26.7 / 24.7 145 / 145
scroll yes yes yes yes 2.9 s + 94.6 s 5.35 MB / 1.69 MB 28.15 MB / 24.55 MB 53.23 / 75.15 46.19 / 63.98 5.95 / 10.95 0.15 / 0.08 25.7 / 24.4 110 / 109
controls yes yes yes yes 2.8 s + 93.0 s 5.22 MB / 1.69 MB 28.03 MB / 24.55 MB 29.98 / 58.24 25.33 / 47.90 1.63 / 8.21 0.10 / 0.10 25.8 / 24.4 133 / 132
svelte yes yes yes yes, modulo node handles 2.9 s + 93.4 s 5.39 MB / 1.69 MB 28.19 MB / 24.55 MB 58.11 / 81.47 49.66 / 69.24 2.26 / 8.62 - / - 25.9 / 24.7 61 / 60

Medians of 21 runs; frame time is the mean over 50 rounds of the taps (tap, due tasks, render frame).

What the numbers say:

  • Running the app is where AOT pays: scripts take 1 to 5 ms on Porffor and 7 to 11 ms on QuickJS, which parses and interprets 163 to 175 KB of Svelte first. Startup as a whole is dominated by engine init (font loading and DOM setup, about 40 to 50 ms and noisy on this machine; the controls row shows the noise), so the end-to-end gain is smaller.
  • Frames are layout and rendering; the JS in a tap is a small part. Porffor is within noise of QuickJS (0.70 against 0.73 ms for a counter tap), not faster.
  • Each app costs about 45 s to build (porf ~1.5 s, clang ~40 s at -O3), against none for QuickJS. The dev loop stays on QuickJS.
  • Porffor’s code is larger than QuickJS’s interpreter: about 3.1 to 3.3 MB for the runtime, glue and a Svelte app, against 1.24 MB for QuickJS plus the binding core (which also needs the app’s source). Whole stripped binaries are 1.7 to 1.9 MB larger.
  • Nodes alive after a full GC differ by one per app here, because these pages detach almost nothing. The lifetime tests show the general case: nothing JS ever wrapped is freed.
test Porffor
dom::aria_attributes_reflect_and_internals_hold_their_own pass
dom::attr_nodes pass
dom::base_element_sets_the_base_url pass
dom::class_list_replace pass
dom::customized_built_in_elements pass
dom::document_replacements_are_validated pass
dom::elements_by_namespace pass
dom::eval_string_returns_the_completion_value pass
dom::fixed_boxes_stay_put_when_the_viewport_scrolls pass
dom::forms_own_their_controls_and_submit pass
dom::html_collections_have_named_items pass
dom::html_element_interfaces pass
dom::inputs_keep_checkedness pass
dom::labels_and_metas_reflect_for_and_http_equiv pass
dom::lone_surrogates_become_replacement_characters pass
dom::move_before pass
dom::namespace_lookups pass
dom::native_buttons_click_when_the_host_presses_them pass
dom::native_controls_report_user_changes_as_trusted_events pass
dom::native_scrolls_fire_scroll_events_in_the_rendering_update pass
dom::other_documents pass
dom::parsed_doctype pass
dom::selects_have_one_selected_option pass
dom::tables_keep_sections_and_rows_in_place pass
dom::taps_click_through_svg_icons_but_never_disabled_controls pass
dom::taps_fire_trusted_mouse_events_at_the_hit_element pass
dom::text_fields_report_typing_and_focus_as_events pass
dom::timers_and_animation_frames_run_in_the_dom_event_loop pass
dom::token_list_replace_checks_empty_tokens_first pass
dom::token_lists_are_iterable pass
dom::url_search_params_iterate_live pass
dom::variadic_mutations_do_not_leak pass
lifetime::detached_nodes_live_while_js_holds_them FAIL: assertion left == right failed: the detached tree is freed once JS drops it
lifetime::errors_are_reported pass
lifetime::expandos_survive_while_connected FAIL: the removed node’s wrapper is released
lifetime::identity_and_listeners pass
streaming::canvas_pixels_round_trip pass
streaming::dataset_maps_data_attributes pass
streaming::host_pointers_become_pointer_events_with_capture pass
streaming::keys_and_wheels_reach_the_page pass
streaming::media_queries_match_the_viewport_and_report_changes pass
streaming::web_socket_failures FAIL: script threw (not compiled ahead of time)
streaming::web_sockets_exchange_messages_and_close FAIL: script threw (not compiled ahead of time)

The 39 that pass are every DOM test, the lifetime tests that do not need a wrapper to be collected, and the streaming tests that do not open a WebSocket. Each test runs in its own process (one engine at a time); the scripts they evaluate are recorded from the QuickJS run with NISO_JS_RECORD. The WebSocket tests put their echo server’s port, picked when the test runs, into their scripts, so the build never has the script the run evaluates.

On Linux x86_64 (alpha 22, 2026-11-09, tests/porffor/run.mjs --runs 3), 45 of 51 binding tests pass: the 4 above fail, and two modules:: tests:

  • modules::with_a_socket_open_a_module_answering_each_read_neither_waits_for_the_timer_nor_spins, for the WebSocket tests’ reason: its script carries the port of a server the test starts.
  • modules::a_module_event_runs_between_a_chain_of_tasks (a chain of 3000 IndexedDB gets with a module event in between), with no message; its cause isn’t known yet.

On Porffor alpha 22 (macOS arm64, debug build, 2026-11-09) the binding tests still pass 40 of 44, the same 4 failing as on main. A probe script built into niso-run-porffor and run with --url http://localhost/ prints the same as on QuickJS. It constructs a Blob and a File, reads blob.text(), creates a blob URL, aborts an AbortController (the listener runs, and the reason is an AbortError), calls AbortSignal.timeout, getRandomValues and randomUUID, and calls crypto.subtle‘s digest and an ECDSA generateKey with a pkcs8 exportKey (138 bytes, the browsers’ encoding). After the move to AWS-LC, a second probe (an RSASSA-PKCS1-v1_5 key’s generation, signature, verification and JWK export, and AES-GCM with a 128-bit IV and a 64-bit tag) prints the same on both engines, and the binding tests are still 40 of 44. After the merge of main’s native modules and workers, 42 of 46 pass, the same 4 failing. That count records the module tests’ scripts too: tests/porffor/run.mjs records only the dom, lifetime and streaming tests, so it reports modules::calls_convert_values and modules::promises_and_events_are_tasks as failing.

Porffor has its own WebCrypto on OpenSSL. A binding named CryptoKey, or a function or variable named crypto, makes porf c compile it in, and the bundle then includes openssl/evp.h. That is the same trap as fetch’s names. So the glue’s class is bound as NisoCryptoKey (its name is still CryptoKey), and the crypto getter’s function is getCrypto (renamed to get crypto).

The aliases only keep the glue’s own names out of that path. They do nothing for an app’s scripts, which are compiled into the same bundle: any script that names crypto or CryptoKey pulls in OpenSSL. On alpha 22, porf c of console.log(2) emits no OpenSSL, while typeof crypto, a local const crypto = 1, a class CryptoKey {} and globalThis.crypto.getRandomValues(...) each emit #include <openssl/evp.h>, err.h and rand.h. So such an app needs OpenSSL to build, next to the AWS-LC niso already links. Until Porffor lets a program opt out of its WebCrypto, an app for Porffor must not name either identifier.

The evaluate hook comes after the two WebAssembly hooks in qe_porf_bind and __niso_bind, which are positional.

A script that names fetch, Headers or Response still fails the build, as on main, so Response.blob() and fetching blob URLs are not reachable on Porffor.

A fetch with a signal keeps its abort algorithm on the signal until every body of its response and their clones is read, or collected unread, which a FinalizationRegistry reports. Porffor’s never calls back, so there a response whose body is never read keeps its abort algorithm, and with it the body’s bytes, for as long as the signal lives.

Radio (examples/radio) and ihatemyphone’s panel and setup pages (examples/ihatemyphone, the niso:phone module) build and run with Porffor as the engine. Neither app’s source changed. QuickJS stays the default; Porffor is chosen per build:

Terminal window
NISO_ENGINE=porffor hosts/android/apk.sh ... # and build.sh, native.sh
NISO_ENGINE=porffor hosts/apple/build.sh ...
NISO_ENGINE=porffor examples/ihatemyphone/build.sh

NISO_ENGINE=porffor turns on the porffor feature of niso-app, niso-android and niso-jvm and points NISO_PORFFOR_SCRIPTS at the app’s bundle, so the app’s scripts are compiled into the library. Three changes made that work:

  • build.rs rewrites the generated porf_data_file to find its data with dladdr in the library that holds it, since Porffor assumes the main executable (porffor-shared-library-assumes-main-executable). It uses the host’s clang only for native builds, so cross builds take the target’s cc (the NDK, Xcode).
  • iOS SDKs have no sys/random.h, which Porffor’s C includes, so bindings/js/porffor/ios/sys/random.h declares getentropy, which iOS’s libSystem exports (porffor-ios-sys-random-missing).
  • The glue copies niso’s navigator and console members onto Porffor’s own objects (see How the binding works). Without it, radio’s navigator.mediaSession was undefined.

Radio calls fetch but builds: Porffor compiles app scripts inside a literal eval, so their fetch does not pull in Porffor’s network runtime.

Two commands rerun the comparison:

Terminal window
node tests/porffor/apps.mjs # headless: niso-run and the JVM page tests
node tests/porffor/devices.mjs [android] [ios] [build] [measure] [--runs N]

tests/porffor/apps.mjs on macOS arm64 (M-series, a heavily loaded host, 2026-11-08). Radio’s station API is answered from tests/porffor/radio-playing.json; the panel and setup pages run their JVM tests against a fake phone module.

page runs DOM matches render stream matches
radio yes yes yes
panel yes yes yes, except 3 frames that differ between two QuickJS runs too
setup yes yes yes
radio, medians of 11 runs Porffor QuickJS
startup 64.05 ms 90.73 ms
engine init 45.61 ms 70.38 ms
scripts 1.38 ms 6.40 ms
max RSS 36.3 MB 35.5 MB
niso-run binary 39.47 MB 32.51 MB

Building the three pages into one Porffor program takes porf 2.4 s and cc 54 to 98 s.

devices.mjs ios measure --runs 7, iPhone 17 simulator, iOS 27, 2026-11-08 (host load average about 30 to 45). Radio is tapped to play by the probe’s button position; it must show Pause and become the Now Playing item at rate 1.

radio Porffor QuickJS
Play tap Play → Pause Play → Pause
Now Playing item, rate おもかげ, 1 おもかげ, 1
cold start, median of 7 509 ms 534 ms
physical footprint 31.4 MB 31.7 MB
Niso.app 56.0 MB 51.1 MB
executable, stripped 40.1 MB 36.1 MB

Cold starts (ms): Porffor 537 504 486 522 509 513 503; QuickJS 549 514 534 537 542 518 524. An earlier run of 7 gave 500 against 537 ms.

Android 17 emulator, 2026-11-08. Radio’s Porffor release APK cold-starts, renders, and plays: tapping Play puts the media session in PLAYING with the song as its metadata, as on QuickJS. Cold start times were not usable (the host’s load average was 53 to 66 and two starts took 3.8 s and 29 s), so there are no Android timings yet; ihatemyphone’s APKs build on Porffor but have not run on the emulator yet.

size Porffor QuickJS
radio release APK, entries compressed 30.0 MB 26.0 MB
radio libniso_android.so, stripped 25.5 MB 21.4 MB
ihatemyphone debug APK, entries compressed 81.5 MB 76.2 MB
ihatemyphone library, unstripped release 43.9 MB 38.6 MB
  • Both apps behave the same on both engines wherever they ran: same DOM and render stream headless, and radio plays on the simulator and the emulator.
  • Startup gains less on a device than headless: the scripts’ 5 ms saved is most of what Porffor wins, and a cold start on iOS is about 500 ms of which the engine is a small part (509 against 534 ms, within the host’s noise).
  • Memory is the same within 1 MB.
  • Porffor adds about 4 MB to an app (the library or executable, stripped), and about 5 MB to radio’s APK and .app.
  • ihatemyphone needs two engines at once when its setup screen is open and the panel opens; on Porffor the second fails (see What blocks shipping).
  • The release APK build (hosts/android/build.sh as apk.sh runs it) did not pass its Gradle properties to Gradle, so release APKs failed with “Cannot query the value of Gradle property ‘nisoJava’”; fixed here.
  1. WeakRef and FinalizationRegistry. Every wrapper the glue makes stays alive (its WeakRef in the wrapper cache is strong) and is never finalized, so host.unref never runs and the DOM never frees a node JS once touched. Both lifetime tests fail for this. Long-running apps (the editor milestone) leak every node they create and drop. Repros: repros/porffor-weakref-holds-target, repros/porffor-finalizationregistry-never-cleans. WeakMap is already weak in Porffor’s GC, so the machinery exists.
  2. No embedding API. The binding depends on runtime internals: static functions it reaches only by being compiled into Porffor’s C file, renaming main, porf_c_stack_top, the try-frame protocol and the promise job runner’s symbol name. Any Porffor release can break it. Shipping needs a supported way to run the top level from a host, call functions both ways, root values held from C, and drain jobs.
  3. One engine at a time. Porffor’s heap and globals are the process’s. Pages can follow one another (a new engine restarts the glue on the same heap), but two at once cannot: ihatemyphone’s panel cannot open while its setup screen is showing in the same process, and an app with a widget extension in-process, or tests that make several DOMs, need a process each (the test runner runs one test per process). It is also why there are no workers: a dedicated worker is a second engine on its own thread, and Porffor’s runtime is not reentrant across threads. On Porffor new Worker() throws a NotSupportedError DOMException, and importScripts cannot be reached. MessageChannel, BroadcastChannel and window.postMessage need no second engine and go through the same glue as on QuickJS; the glue compiles on Porffor, but no Porffor test runs them yet. A setTimeout string handler runs as a classic script through host.evalScript, so on Porffor it must be one of the scripts the program was built with (NISO_JS_RECORD records it). Workers would need the runtime’s heap and globals to become per-thread state; their scripts could be compiled ahead of time with the page’s, as module workers name their script statically.
  4. Scripts are fixed at build time. There is no runtime eval, so a host can only run scripts it was built with. That is fine for a shipped app bundle, but the scripts run as direct evals: their top-level declarations do not become globals as they would in a classic script, because indirect eval is broken (repros porffor-indirect-eval-closure-crash, porffor-indirect-eval-var-not-global). Runtime globals only eval code uses are missing (porffor-eval-misses-runtime-globals; niso now defines queueMicrotask in the glue).
  5. iOS is verified on the simulator only. Radio runs on the iOS simulator (see Device apps); not on a phone yet. Porffor reserves a 4 GB arena (PORF_ARENA_RESERVE, mapped at a fixed hint), which may need care within a phone’s address space limits; the C itself is portable and has no JIT.

Also found: a named class expression created in a function that is handed globalThis leaks its name as a global (porffor-class-expression-name-leaks). niso’s glue declares its classes, except Headers and Response, whose leaked names are the globals the glue defines from them anyway.

Porffor has no WebAssembly global. The JS API requires Memory.buffer to be an ArrayBuffer over the memory’s own bytes, and Porffor’s ArrayBuffers live in its own heap with no way to wrap external memory, so the engine’s qe_can_alias returns false and the glue leaves WebAssembly undefined. Scripts see what a browser without WebAssembly shows them, rather than an API that copies memory and silently loses writes. A copying Memory.buffer, or an ArrayBuffer that Porffor allocates and wasmi then uses as linear memory, would need either a Porffor API for external buffers or a wasmi memory backed by foreign bytes, neither of which exists.

With main merged, the WebAssembly branch builds on Porffor alpha 22 (a081ebd) and passes 40 of the 44 binding tests (debug build, macOS arm64, the recipe under How the binding works): main’s 39, plus lifetime::hidden_containing_blocks_let_go_of_freed_boxes. The 4 that fail are the same two lifetime and two WebSocket tests.

Two things were needed:

  1. Porffor binds the two WebAssembly hooks after microtask, where QE_HOOK_WASM_CALL and QE_HOOK_WASM_TASK sit in the hook table (qe_porf_bind and __niso_bind are positional), and implements qe_push_str.
  2. A parameter named module in the WebAssembly glue made porf c compile in ada, so build.rs refused the program as if a script named URL. Found by bisecting the bundle; a lone (module) => … does not do it, so there is no repro. The glue names those parameters moduleObject.

Each directory under repros/ has repro.js, expected.txt (node), actual.txt (Porffor alpha 22) and ISSUE.md, ready to file. None duplicates an open or closed issue in CanadaHonk/porffor (searched 2026-11-08; #400, a class expression’s name inside its own class, is fixed and different). Porffor’s AI_POLICY.md asks for AI use to be disclosed and issue text written by a person: the drafts say how they were made and should be reviewed and rewritten before filing.

repro what
porffor-weakref-holds-target deref() still returns the target after it was collected
porffor-finalizationregistry-never-cleans cleanup callbacks never run; targets are held strongly
porffor-indirect-eval-closure-crash (0, eval)('const x = 1; const f = () => x; f()') crashes the compiler
porffor-indirect-eval-var-not-global indirect eval’s var is not a global, even to its own closures
porffor-eval-misses-runtime-globals queueMicrotask, setTimeout, structuredClone are undefined in eval code
porffor-class-expression-name-leaks a class expression’s name becomes a global and shadows the real one
porffor-own-fetch-pulls-network-runtime a program’s own fetch compiles in libuv, OpenSSL, ada and uWebSockets
porffor-own-url-class-replaced a top-level class URL is replaced by Porffor’s URL, and compiles in ada
porffor-own-messagechannel-class-replaced a top-level class MessageChannel compiles in libuv
porffor-navigator-console-global-ignored a program’s own navigator or console global is ignored for Porffor’s
porffor-shared-library-assumes-main-executable porf_data_file finds its data next to the main executable, not the library that holds it
porffor-ios-sys-random-missing the generated C includes sys/random.h, which iOS SDKs lack
porffor-date-now-hook a request, not a bug (ISSUE.md only): Date.now() reads the system clock and a host cannot supply its own, so it does not skip ahead with niso’s virtual time