Skip to content

feat(modules): async loader for dynamic import()#1522

Open
regularkevvv wants to merge 1 commit into
quickjs-ng:masterfrom
regularkevvv:feat/async-dynamic-import-loader
Open

feat(modules): async loader for dynamic import()#1522
regularkevvv wants to merge 1 commit into
quickjs-ng:masterfrom
regularkevvv:feat/async-dynamic-import-loader

Conversation

@regularkevvv
Copy link
Copy Markdown

What

Adds JS_SetModuleLoaderFuncAsync, JS_FulfillAsyncModuleLoad, and JS_RejectAsyncModuleLoad so embedders can satisfy import(specifier) from an asynchronous source (e.g. a network fetch) without blocking the JS thread. The loader receives an opaque handle and settles it later.

Addresses

#196 — "make dynamic import actually async" (primary):

  • The request in #196: a distinct callback for dynamic modules like V8's SetHostImportModuleDynamicallyCallback, so "loading a dynamic module shouldn't itself be blocking" and can "integrate with nonblocking I/O … associate the Promise with our nonblocking I/O operation and, on completion, resolve the promise." → exactly this API. The scaling concern raised there — "Some websites do hundreds of dynamic async imports" — is what motivated the in-flight dedup + concurrency tests below.
  • Noted in the thread: "dynamic import is different from regular import." → justifies a dedicated async hook scoped to import() only.

#191 — "async es module loader" (this PR is the dynamic half):

Scope / compatibility

Dynamic import() only; static imports keep using the synchronous JSModuleLoaderFunc[2], mirroring the HTML spec split between HostImportModuleDynamically and HostResolveImportedModule. No behavior change and no cost when no async loader is registered; the existing loader union is untouched, so embedder ABI is unchanged.

Design notes

  • Specifier is normalized; a cached module is settled without calling the loader.
  • Concurrent import()s of the same specifier are de-duplicated: the loader runs once and the module is evaluated once via a single-eval fan-out, so all callers resolve to the same namespace.
  • Settling a handle more than once is a safe no-op; handles still in flight are reclaimed by JS_FreeRuntime without running JS during teardown.

Note on handle vs. returned Promise

The loader gets an opaque handle to settle later (JS_Fulfill/RejectAsyncModuleLoad), rather than returning a JS Promise like V8's SetHostImportModuleDynamicallyCallback — the shape referenced here. JS behavior is identical; only the C-facing shape differs. I went with the handle because it keeps C embedders from having to build and refcount Promise objects, and because it makes in-flight dedup natural: concurrent import()s of one specifier attach as extra waiters on a single handle (one load, one evaluation).

Glad to switch to the returned-Promise shape if you'd prefer — it's a localized change to the loader signature and js_dynamic_import_job, and doesn't touch the dedup/shutdown machinery.

Reviewer concern → test that asserts it

All in api-test.c, run under JS_ABORT_ON_LEAKS; the whole binary also passes under ASAN+UBSAN.

Likely concern Test
Concurrent same-spec imports double-load / break the module singleton inflight_dedup — loader called once, evaluated once, import()===import()
Dedup isn't over-eager (distinct specifiers each load once) concurrent_distinct — 64 concurrent, 64 loads/64 evals
Double fulfill/reject → UAF/double-free double_settle
Leak if host never settles a handle shutdown_pending
Leak on shutdown with a multi-waiter handle (dedup path) shutdown_multi_waiter
Static deps of an async module wrongly routed async transitive — static dep goes through the sync loader
Top-level await (explicitly doubted in #191) tla
Re-entrant synchronous settle from inside the loader callback reentrant
Import attributes honored / rejectable attrs_reject — rejects before the loader runs
Normalizer hook actually invoked normalize
Nested dynamic import() from an async module nested_dynamic
NULL module → clean rejection (not crash) null_module
Cache hit doesn't re-fetch cache_hit
Rejection paths (loader error / module throws) reject, eval_throw

Test results

make test (test262) 0/63 errors · make ctest (C11, both JS_NAN_BOXING modes) · make cxxtest (C++11, both modes) · api-test clean under ASAN+UBSAN — all green, no new warnings.

Add JS_SetModuleLoaderFuncAsync together with JS_FulfillAsyncModuleLoad
and JS_RejectAsyncModuleLoad so embedders can satisfy import(specifier)
from an asynchronous source (e.g. a network fetch) without blocking the
JS thread.

Scope is limited to dynamic import(); static imports keep using the
synchronous JSModuleLoaderFunc[2], mirroring the HTML spec split between
HostImportModuleDynamically and HostResolveImportedModule.

The loader is handed an opaque handle and settles it later. Properties:
- the specifier is normalized and a cached module is settled without
  calling the loader;
- concurrent import()s of the same specifier share one load: the loader
  runs once and the module is evaluated once;
- settling a handle more than once is a no-op, and handles still in
  flight are reclaimed by JS_FreeRuntime.

Tested in api-test.c: fulfill, reject, transitive static import, cache
hit, in-flight dedup, top-level await, re-entrant settle, attribute and
normalizer hooks, nested dynamic import, and shutdown sweeps.
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.

[feature request] async es module loader

1 participant