perf: lazy-load more modules in the snapshot#34061
Conversation
Adds an env-var-gated graph collector that records every ESM static import,
op_lazy_load_esm call, and loadExtScript call observed during snapshot
creation. Set DENO_SNAPSHOT_IMPORT_GRAPH=<path> when building to emit one
JSON edge per line at <path>; otherwise it's a no-op. Each record carries
{from, to, kind} where kind is "esm" | "lazy_esm" | "lazy_script". The
caller of lazy_esm/lazy_script edges is recovered by walking the v8 stack
and skipping the ext:core/01_core.js wrapper frame. Used to identify
modules that anchor entire subtrees in the snapshot's static closure.
Every lazy_loaded_js script's IIFE preamble destructures globalThis.__bootstrap to get at core/primordials/internals, but runtime bootstrap (runtime/js/99_main.js) deletes that property to hide internals from user code. So a script that's residual (or otherwise loaded after bootstrap completes) sees `undefined` and throws. Capture the bootstrap object into a closure variable at snapshot eval time and have loadExtScript reinstall it on globalThis for the duration of the synchronous op_load_ext_script call, then remove it again in a finally. The whole window is synchronous JS so no other code observes the temporary reinstall. Removes a previously-implicit precondition that lazy_loaded_js entries must be consumed at snapshot time.
When a lazy_loaded_js entry is consumed at snapshot time it's compiled via the snapshot's extension_transpiler, so what ends up in the snapshot blob is plain JS. When the same entry is residual (not consumed during snapshot), build.rs previously include_str!'d the raw file. That works for .js/.mjs entries but fails at runtime for .ts entries because loadExtScript hands the raw TypeScript directly to v8::Script::compile, which throws on any TS-only syntax (\`this: any\` parameter annotations, type imports, etc.). For each residual lazy_loaded_js file, run deno_runtime::transpile:: maybe_transpile_source (the same function the snapshot path uses), write the resulting JS to \$OUT_DIR/residual_sources/<sanitized>.js, and include_str! that. lazy_loaded_esm entries are unchanged: they go through op_lazy_load_esm which already transpiles via the module loader at runtime.
deprecate() previously called \`process ??= lazyLoadProcess()\` in its body, so wrapping a function loaded node:process eagerly. assert.ts's body calls deprecate(CallTracker, ...) at module scope, which means loading assert.ts forces node:process to evaluate. process.ts's body in turn imports node:path, whose body loadExtScripts path/_win32.ts, which loadExtScripts assert.ts. With anything else also triggering an assert.ts load (e.g. once we lazify upstream entry points), this becomes a snapshot-time circular dependency. Move the lazyLoadProcess() call inside the returned \`deprecated\` wrapper so node:process only loads when the deprecated function is actually invoked. The noDeprecation fast-path now runs per-invocation instead of being baked into the wrapper choice at deprecate() time; the cost is negligible and we keep deprecate() side-effect-free.
01_require.js eagerly imports node:_http_agent/_common/_outgoing/_server and eagerly loadExtScripts http.ts/http2.ts/https.ts at module body time, so the entire node:_http_* + node:net + node:stream subtree gets materialized into the snapshot whether or not the program actually uses HTTP. Install the seven entries as one-shot lazy getters on nativeModuleExports via createLazyLoader (for the ESM _http_* modules) and loadExtScript thunks (for http/http2/https). The getter fires on first access and replaces itself with a data property, so subsequent require()s are zero-overhead. Presence-check sites that previously did \`nativeModuleExports[id]\` now use \`in\` to avoid forcing the load. Snapshot shrinks by ~10 KB; no public API change.
12dfc97 to
152e138
Compare
… but keep node:stream eager
Previous commits in this stack tried to make \`node:stream\` itself lazy.
That was wrong for startup time: every Deno program ends up loading
node:stream at runtime startup, because
\`__bootstrapNodeProcess(warmup=false)\` calls
\`createWritableStdioStream(io.stdout, "stdout")\` ->
\`new (lazyStream().Writable)({...})\` for \`process.stdout\` and
\`process.stderr\` regardless of whether the program ever uses streams.
Lazy-loading a module that everyone loads is a net startup-time loss
(roughly an 11% regression observed by the user) — parse+compile at
startup is slower than v8 snapshot deserialization.
So node:stream stays in \`esm\`, and node:stream/promises with it.
What does end up lazy is the surrounding chain that doesn't load at
startup unless the user touches it:
* \`node:repl\` moves from \`esm\` to \`lazy_loaded_esm\` (no program needs
repl at startup outside \`deno repl\`).
* \`01_require.js\` switches \`tls\`/\`net\`/\`repl\`/\`fs/promises\`/
\`_tls_common\`/\`_tls_wrap\`/\`internal/repl\`/\`internal/crypto/cipher\`
into \`lazyNodeModules\`.
* \`02_init.js\` only calls \`__setupChildProcessIpcChannel\` when
\`op_node_child_ipc_pipe()\` reports a parent pipe; otherwise
\`child_process.ts\` (and its node:stream-extending classes) never
evaluate at runtime.
* \`runtime/js/99_main.js\` drops \`nodeBootstrap({warmup: true})\`. The
warmup branch only built placeholder stdin/stdout/stderr streams
that the non-warmup branch then unconditionally overwrites, so its
only observable effect was pulling node:stream + node:net into the
snapshot at build time.
* \`ext/node/polyfills/fs.ts\`: \`Utf8Stream\` becomes a getter on the
return object so loading fs.ts at snapshot eval doesn't immediately
pull \`internal/streams/fast-utf8-stream.js\` (which statically imports
node:fs and forces the fs_esm.ts namespace to materialize, which in
turn fires all the lazy stream getters off the fs.ts return object).
* \`ext/node/polyfills/_process/streams.mjs\`: \`initStdin\` calls
\`lazyTty()\` before \`new readStream(fd)\` in the TTY case. With
\`node:tty\` lazy, we have to force its body to evaluate
(\`setReadStream\` is the side effect) before bootstrap uses the
constructor.
Instrumentation added in this commit (companion to the existing
\`DENO_SNAPSHOT_IMPORT_GRAPH\` knob from earlier in the stack):
* New env var \`DENO_LOG_LAZY_LOAD=1\` prints a stderr line each time a
lazy_loaded_esm entry actually loads (cache miss) or a
lazy_loaded_js entry actually parses at runtime. Cache hits are
suppressed. Use to see what's parsed at startup vs on-demand.
* \`lazy_load_esm_module\` distinguishes the cache-hit path
(\`record_lazy_esm_cached\`, graph-only) from the actual-load path
(\`record_lazy_esm\`, graph + stderr).
Verified with \`DENO_LOG_LAZY_LOAD=1\`:
* \`deno eval 'console.log(1)'\` -> 0 lazy loads at startup
* \`deno run hello.js\` (no imports) -> 0 lazy loads
* \`deno run\` + \`import "node:crypto"\` -> 3 lazy loads (paid by users of crypto)
* \`deno run\` + \`import "node:http"\` -> 9 lazy loads (paid by users of http)
Snapshot blob shrinks from 11,438,579 -> 9,964,016 bytes
(-1.41 MB / -12.9%) across the full commit stack.
Smoke-tested: hello.js, deno eval, node:stream Readable piping,
node:fs readFileSync, node:fs/promises readFile, node:zlib gzipSync,
node:crypto hash, node:tls, node:net, HTTP server + fetch,
process.stdout.write, process.stdin.isTTY.
…oad_esm_module \`lazy_load_esm_module\` previously held \`self.data.borrow()\` across the \`module.evaluate(scope)\` call in the cache-hit path. When the cached module had been instantiated but not yet evaluated, that evaluate would trigger V8 to recursively compile dependent modules, which calls back into \`new_module_from_js_source\` and tries to \`self.data.borrow_mut()\` at line 959 -- panic with "RefCell already borrowed". Pre-existing bug, but easier to hit now that more node-compat modules go through the lazy ESM path at runtime. Repro: \`deno run -A npm:rolldown\` on the lazified stack. Fix: collect the cached handle inside a scoped borrow, drop the borrow, then evaluate. Functionally identical to the old path otherwise.
Lazify all globals in 98_global_scope_shared.js that pull the
web-streams polyfill (06_streams.js, 208 KB source):
- ReadableStream / WritableStream / TransformStream and their inner
controllers / readers (13 stream classes)
- CompressionStream / DecompressionStream
- Request / Response / fetch / EventSource (chain via 22_body)
- Cache / CacheStorage / caches
Each global is converted from `core.propNonEnumerable(streams.X)` to
`core.propNonEnumerableLazyLoaded(s => s.X, lazyStreams)` so the
underlying ext file isn't loaded until first access. `fetch` keeps a
data descriptor whose value is a wrapper function (so node:test's
`mock.method` can still mock it via descriptor.value).
Side fixes:
- runtime/js/99_main.js: stop spreading denoNs with `{...denoNs}` -
spread invokes every getter, defeating lazy descriptors. Use
ObjectDefineProperties + getOwnPropertyDescriptors. Same for the
unstable feature merge loop.
- 99_main.js: wrap the wasm-streaming callback and defer
registerDeclarativeServer load to the addMainModuleHandler callback.
- ext/web/13_message_port.js: drop top-level streams import; move
markNotSerializable registration into 06_streams.js itself
(inverts the dep so message_port no longer drags streams).
- ext/node/polyfills/01_require.js: lazify internal/child_process
(40_process -> 22_body chain) and stream/web (14_compression chain).
- ext/node/polyfills/internal/streams/fast-utf8-stream.js: replace
static `import * as fs from 'node:fs'` with a lazy loader, since
this module is loaded via the fs.Utf8Stream getter while node:fs
is mid-evaluation; a static import re-enters node:fs and TDZ-traps
on `lazyUtf8Stream().default`.
- ext/node/polyfills/internal/fs/{handle,promises}.ts: defer the
top-level `promisify(lazyFs().X)` calls to first call. Same
cycle: node:fs's `export const promises = mod.promises` line
re-triggers `get promises` while `lazyInternalPromises().default`
is in TDZ.
Snapshot: 9,980,849 -> 7,331,556 bytes (-2.65 MB, -26.5%). Verified
zero startup lazy-loads in both TTY and pipe modes.
49c79d6 to
4407118
Compare
|
Failures look real across all platforms — the lazy-load polyfill change appears to alter snapshot-induced stack frames. |
fibibot
left a comment
There was a problem hiding this comment.
CI is red across 30 jobs (all 6 platforms × test unit / test specs / test libs / test node_compat / deno_core / wpt). Failures are caused by this PR — the function wrappers added to defer streams/fetch/serve initialization change stack-frame shape, breaking tests that assert on stack traces.
Concrete example: tests/specs/run/wasm_streaming_panic_test/wasm_streaming_panic_test.js.out expects:
at handleWasmStreaming (ext:deno_fetch/26_fetch.js:[WILDCARD])
After this PR the frame becomes at Object.handleWasmStreaming plus an extra 99_main.js:472 frame from runtime/js/99_main.js where the wasm-streaming callback is now wrapped. Same shape-change is the likely cause of unit::{globals,http,serve}_test and node_compat::parallel::test-inspector-* failures.
Two options: (1) make the wrappers preserve function name + avoid adding a frame (e.g. Object.defineProperty(..., \"name\", ...) + tail-call the real handler so V8 elides the wrapper frame), or (2) update the affected test expectations to match the new stack shape.
# Conflicts: # ext/node/polyfills/01_require.js # libs/core/modules/map.rs
fibibot
left a comment
There was a problem hiding this comment.
CI is still red on the new head, and at least one failure is caused by the bootstrap change rather than a flake.
globalThis.__bootstrap = internals.capturedBootstrapleaves an internal_-prefixed global installed after startup in bothbootstrapMainRuntimeandbootstrapWorkerRuntime.tests/unit/globals_test.tsexplicitly asserts thatObject.keys(globalThis)has no internal globals, so the assignment atruntime/js/99_main.js:813/:954makes every runtime expose__bootstrapand explains the unit shard failures. Keep the captured bootstrap hidden behindDeno[Deno.internal], a non-enumerable symbol/private slot, or teach the lazy loader to use a captured value without reintroducing an enumerable global.
lunadogbot
left a comment
There was a problem hiding this comment.
globalThis.__bootstrap = internals.capturedBootstrap;reintroduces an enumerable internal global in both bootstrap paths. Plain assignment creates an enumerable own property, soObject.keys(globalThis)now includes__bootstrapandtests/unit/globals_test.ts'snoInternalGlobalsassertion fails. Keep the captured bootstrap offglobalThis, or install it withObjectDefineProperty(..., { enumerable: false })if the lazy loader really needs that lookup.
CI is red on the unit shards for this failure.
Lazy scripts previously destructured globalThis.__bootstrap at module body. With lazification, scripts evaluate after 99_main.js deletes that global, so the destructure failed. The old fix (temporarily reinstalling __bootstrap on globalThis around each load_ext_script call) was hacky and leaked the global to user code via direct property access.
Instead, compile each lazy script with v8::script_compiler::compile_function with one named parameter "__bootstrap", wrap the source as "return ({source});", and pass the captured snapshot-time bootstrap view as the argument. Scripts reference the free "__bootstrap" variable. globalThis.__bootstrap is never repopulated after the bootstrap delete.
cbb6e99 to
16bb1b9
Compare
5afcba5 to
fbede64
Compare
…isteners `AbortSignal` stored only a WeakRef from source signals back to their `AbortSignal.any` dependents. The previous addEventListener path only installed a strong backref via `activeDependents` when the source had a `timerId` (timer signals), so for regular `AbortController.signal` sources, a GC between `AbortSignal.any(...)` and `source.abort()` could collect the dependent and the abort would silently fail to propagate. Now we add the dependent to `source[activeDependents]` (a strong Set) whenever the dependent has `abort` listeners, independent of timer sources. The timer-loop ref behaviour stays as an additional step on top.
fbede64 to
1ef9b50
Compare
…cache assign The line tries to write a value to a property that's defined as a getter-only accessor on EventEmitter. In strict mode (which every polyfill IIFE now runs under) that throws TypeError instead of silently no-oping, breaking every consumer of node:events on the first call to `EventEmitter.EventEmitterAsyncResource`. The closure-captured `_EventEmitterAsyncResource` already caches the class for subsequent calls, so the assignment was dead weight.
1ef9b50 to
fa8957d
Compare
|
@nathanwhitbot review this PR thoroughly |
nathanwhitbot
left a comment
There was a problem hiding this comment.
Reviewed the current head (fa8957dcbf12e2e50bf461fd99fe7b96be7d55e1) with focus on the lazy loadExtScript path, captured __bootstrap handling, Deno/global descriptor preservation, lazy Node native module registration, and the streams/fetch/serve/process chains.
I do not see a blocking issue in the current revision. The earlier review concerns around exposing globalThis.__bootstrap and stack-shape CI failures appear addressed: __bootstrap is no longer left on the global object, finalDenoNs/unstable namespace merging now preserves descriptors without forcing lazy getters, and the current CI matrix is green except for jobs still pending at the time I checked.
Local smoke checks I ran against the checked-out debug binary:
Object.keys(globalThis).includes("__bootstrap")is false.DENO_LOG_LAZY_LOAD=1 deno eval "console.log(1)"prints no lazy-load entries before user code.require("node:http"),require("node:fs/promises"), andrequire("node:stream/web")load and return expected exports.structuredClone(new ReadableStream())still rejects withDataCloneErrorafter streams self-register their prototypes.AbortSignal.any()dependent abort propagation survived an exposed-GC smoke probe.
I attempted ./x test-unit globals, but this worker does not have target/debug/test_server, so the local unit harness could not start. CI has the corresponding unit shards passing on the current run.
|
@fibibot re-review |
|
@lunadogbot re-review |
…ad (#34469) A dynamic `import()` of an async module graph could hang and then exit with "Top-level await promise never resolved" when one of the graph's sibling deps triggered a nested lazy ESM load — the shape that npm CJS packages routinely hit since #34061 moved many `node:*` polyfills behind `createLazyLoader`. Minimal repro: `await import("./mod.ts")` where `mod.ts` is `import "./tla.ts"; import "npm:undici";` and `tla.ts` is `await Promise.resolve();`. `lazy_load_esm_module` runs a `perform_microtask_checkpoint()` after evaluating the lazy module, gated by `!evaluating_top_level`. That flag was only set in `mod_evaluate` (main module path), not in `dynamic_import_module_evaluate`, so during a dyn-imported async graph the checkpoint would fire while V8 was still inside the outer `module.evaluate()`. V8 would drain the TLA dep's await-resume and run `AsyncModuleExecutionFulfilled`, but couldn't then run `ExecuteModule` on the still-evaluating parent — leaving the parent's evaluation promise stuck in Pending and V8 reporting the user-facing top-level `await import(...)` as stalled. This commit sets `evaluating_top_level` around `module.evaluate()` in the dynamic import path too, matching `mod_evaluate`, so nested lazy ESM loads skip their post-evaluate checkpoint. Fixes #34466
… GC (#34516) ## Summary Adds a regression test that mirrors the report in #29075: take `AbortSignal.any()` of an `AbortController`-backed signal, register an `"abort"` listener on the returned temporary, force a major GC, then abort the controller and assert the listener fires. Per the spec ([abort-signal-garbage-collection](https://dom.spec.whatwg.org/#abort-signal-garbage-collection)), a non-aborted dependent `AbortSignal` must not be GC'd while its source signals set is non-empty and it has registered listeners for the `abort` event. The fix landed in #34061 — `addEventListener` now pins the dependent into every source signal's `activeDependents` set (not just timer sources). Without that pin, the temporary returned from `AbortSignal.any([ac.signal])` is only referenced via `WeakRef` from `ac.signal`'s `dependentSignals`, so V8 collects it and the listener silently disappears. The existing regression test (`tests/specs/run/abort_signal_any_gc`) only exercises the timer-source path: it includes `AbortSignal.timeout(500)` in the source list, which the partial fix in #32118 already covered. This new test exercises the controller-only path that the issue reported. Verified the test: - prints `Aborted` and passes on builds containing #34061 (locally-built `main` and canary `c39514d10a`+) - produces no output and would fail the assertion on Deno 2.7.14 and earlier (only the timer-source pinning was in place) ## Test plan - [ ] `cargo test --test specs -- abort_signal_any_controller_gc` - [ ] Existing `cargo test --test specs -- abort_signal_any_gc` still passes - [ ] WPT `AbortSignal.any.html` continues to pass Closes denoland/orchid#260 Co-authored-by: divybot <[email protected]> Co-authored-by: Divy Srivastava <[email protected]>
## Summary
Lazifies a large fraction of the JS code currently baked into the CLI
startup snapshot. End result:
| | Snapshot blob | Delta |
| --- | ---: | ---: |
| Before this stack (main) | ~11.4 MB | — |
| After | **7,331,556 bytes** | **−~3.1 MB / −27% from main** |
| (final commit of stack — web-streams lazification alone) | 9,980,849 →
7,331,556 | −2.65 MB |
Verified with `DENO_LOG_LAZY_LOAD=1 deno run hello.js`: **0 lazy loads
at startup**, in both TTY and pipe stdout modes. A
non-`fetch`/`stream`/`fs.promises`/`node:repl` program no longer pays
parse cost for any of those subtrees.
## What's now lazy
### Web platform (final commit)
The 208 KB `06_streams.js` polyfill and every ext module that pulls it:
- `ReadableStream` / `WritableStream` / `TransformStream` and all their
inner controllers/readers (13 stream classes)
- `Request` / `Response` / `fetch` / `EventSource` (chain through
`22_body.js` → `06_streams.js`)
- `caches` / `CacheStorage` / `Cache`
- `CompressionStream` / `DecompressionStream`
- `node:stream/web`
- `Deno.serve` / `Deno.serveHttp` / `Deno.upgradeWebSocket` /
`Deno.Command` / `Deno.run` / `Deno.spawn*` / `Deno.kill` /
`Deno.openKv`
### Node polyfills (earlier in stack)
- HTTP cluster: `node:http` / `node:http2` / `node:https` /
`node:_http_*` / `node:internal/http*`
- Crypto cluster: `node:crypto` /
`node:internal/crypto/{cipher,hash,...}`
- Streams cluster: `node:zlib`, `node:repl`, `node:internal/repl`,
`node:readline`, `node:readline/promises`
- Process cluster: `node:child_process`, `node:internal/child_process`,
`node:dgram`, `node:cluster`
- TLS cluster: `node:tls`, `node:_tls_common`, `node:_tls_wrap`
- Misc: `node:fs/promises`, `node:assert/strict`,
`node:internal/event_target`, `node:internal/fs/utils`
Kept eager (loading them is on the hot path of every program):
`node:stream`, `node:stream/promises`, `node:net`, `node:tty`,
`node:module`, `node:process`.
## Overview of changes
### Infrastructure (`build(snapshot)` + `refactor(core)`)
- **`DENO_SNAPSHOT_IMPORT_GRAPH=<file>`** env var: dump JSONL of every
esm/lazy-script edge during snapshot build. Used to identify exactly
which scripts are dragging which polyfills into the snapshot.
- **`DENO_LOG_LAZY_LOAD=1`** runtime env var: prints a stderr line each
time a lazy_loaded_esm / lazy_loaded_js entry actually parses at
runtime. Cache hits suppressed.
- **Captured `__bootstrap`** in `01_core.js` so deferred `loadExtScript`
calls still find `core`/`primordials`/`internals` after `99_main.js`
deletes `globalThis.__bootstrap`.
- **Residual `.ts` transpile in `build.rs`**: pre-transpile any
`lazy_loaded_js` / `lazy_loaded_esm` file that wasn't consumed at
snapshot time so the runtime loader receives parseable JS rather than
TypeScript.
- **Lazy-ESM resolve fallback**: in `module_map`, if static-import
resolve fails, fall back to the lazy ESM source list before erroring
(lets `node:_http_*` re-export work without eager registration).
### Bug fixes pulled out of the lazification work
- `fix(ext/node)`: defer `lazyLoadProcess()` to `deprecated()` wrapper
to break the `assert.ts ↔ process.ts` cycle exposed by lazification.
- `fix(core)`: drop the module-map borrow before recursively
re-evaluating a lazy ESM module — the prior code held it across
`module.evaluate(scope)` and panicked on `RefCell::borrow_mut` during
recursive lazy_load_esm.
### Final commit — web-streams chain
- `runtime/js/98_global_scope_shared.js`: converts every streams-pulling
global to `propNonEnumerableLazyLoaded` / wrapper-function form.
- `runtime/js/99_main.js`: stops spreading `denoNs` with `{...denoNs}`
(which invokes every getter); uses `ObjectDefineProperties +
getOwnPropertyDescriptors` instead. Same fix for the unstable-feature
merge loop. Wraps the wasm-streaming callback and defers
`registerDeclarativeServer` to the `addMainModuleHandler` callback.
- `ext/web/13_message_port.js`: drops top-level streams import;
`markNotSerializable` registration moved into `06_streams.js` itself
(inverts the dep so message_port no longer drags streams).
- `ext/node/polyfills/01_require.js`: lazifies `internal/child_process`
(which pulled `40_process.js → 22_body.js`) and `stream/web` (which
pulled `14_compression.js`).
- `ext/node/polyfills/internal/streams/fast-utf8-stream.js`: replaces
`import * as fs from "node:fs"` with `createLazyLoader("node:fs")`. The
static import was re-entering `node:fs`'s evaluating body and
TDZ-trapping on `lazyUtf8Stream().default`.
- `ext/node/polyfills/internal/fs/{handle,promises}.ts`: defers every
top-level `promisify(lazyFs().X)` to first-call wrappers. Same TDZ
cycle: `node:fs`'s `export const promises = mod.promises` line
re-triggers `get promises` while `lazyInternalPromises().default` is
still in TDZ.
## Outcome
| Surface | Improvement |
| --- | --- |
| Snapshot size | 11.4 MB → **7.33 MB** (−3.1 MB / −27%) |
| `deno run empty.js` startup parses | 0 lazy loads in TTY and pipe
modes |
| `import 'node:crypto'` cost | Paid by users of crypto (3 lazy loads) |
| `import 'node:http'` cost | Paid by users of http (9 lazy loads) |
| `fetch('...')` first-call cost | Loads `26_fetch.js` + `22_body.js` +
`06_streams.js` on demand |
Programs that don't touch streams/fetch/http/repl/Deno.serve no longer
pay the parse cost.
## Test plan
- [ ] `cargo test` passes
- [ ] `cargo test --test node_compat` passes (down from 43 → ~38 fails,
the remainder are pre-existing on main: `IO Safety violation` in `fork`
and the v8 weak-handle GC flake in `test-repl-tab-complete-buffer`)
- [ ] `DENO_LOG_LAZY_LOAD=1 deno run empty.js` prints 0 lazy loads (TTY
and pipe)
- [ ] Smokes: `Deno.serve`, `fetch`, `new
ReadableStream/Request/Response`, `structuredClone(new
ReadableStream())` rejection, `fs.promises.readdir/readFile`,
`node:child_process.spawn`, `node:stream/web`
---------
Co-authored-by: Bartek Iwańczuk <[email protected]>
…ad (denoland#34469) A dynamic `import()` of an async module graph could hang and then exit with "Top-level await promise never resolved" when one of the graph's sibling deps triggered a nested lazy ESM load — the shape that npm CJS packages routinely hit since denoland#34061 moved many `node:*` polyfills behind `createLazyLoader`. Minimal repro: `await import("./mod.ts")` where `mod.ts` is `import "./tla.ts"; import "npm:undici";` and `tla.ts` is `await Promise.resolve();`. `lazy_load_esm_module` runs a `perform_microtask_checkpoint()` after evaluating the lazy module, gated by `!evaluating_top_level`. That flag was only set in `mod_evaluate` (main module path), not in `dynamic_import_module_evaluate`, so during a dyn-imported async graph the checkpoint would fire while V8 was still inside the outer `module.evaluate()`. V8 would drain the TLA dep's await-resume and run `AsyncModuleExecutionFulfilled`, but couldn't then run `ExecuteModule` on the still-evaluating parent — leaving the parent's evaluation promise stuck in Pending and V8 reporting the user-facing top-level `await import(...)` as stalled. This commit sets `evaluating_top_level` around `module.evaluate()` in the dynamic import path too, matching `mod_evaluate`, so nested lazy ESM loads skip their post-evaluate checkpoint. Fixes denoland#34466
… GC (denoland#34516) ## Summary Adds a regression test that mirrors the report in denoland#29075: take `AbortSignal.any()` of an `AbortController`-backed signal, register an `"abort"` listener on the returned temporary, force a major GC, then abort the controller and assert the listener fires. Per the spec ([abort-signal-garbage-collection](https://dom.spec.whatwg.org/#abort-signal-garbage-collection)), a non-aborted dependent `AbortSignal` must not be GC'd while its source signals set is non-empty and it has registered listeners for the `abort` event. The fix landed in denoland#34061 — `addEventListener` now pins the dependent into every source signal's `activeDependents` set (not just timer sources). Without that pin, the temporary returned from `AbortSignal.any([ac.signal])` is only referenced via `WeakRef` from `ac.signal`'s `dependentSignals`, so V8 collects it and the listener silently disappears. The existing regression test (`tests/specs/run/abort_signal_any_gc`) only exercises the timer-source path: it includes `AbortSignal.timeout(500)` in the source list, which the partial fix in denoland#32118 already covered. This new test exercises the controller-only path that the issue reported. Verified the test: - prints `Aborted` and passes on builds containing denoland#34061 (locally-built `main` and canary `c39514d10a`+) - produces no output and would fail the assertion on Deno 2.7.14 and earlier (only the timer-source pinning was in place) ## Test plan - [ ] `cargo test --test specs -- abort_signal_any_controller_gc` - [ ] Existing `cargo test --test specs -- abort_signal_any_gc` still passes - [ ] WPT `AbortSignal.any.html` continues to pass Closes denoland/orchid#260 Co-authored-by: divybot <[email protected]> Co-authored-by: Divy Srivastava <[email protected]>
Summary
Lazifies a large fraction of the JS code currently baked into the CLI startup snapshot. End result:
Verified with
DENO_LOG_LAZY_LOAD=1 deno run hello.js: 0 lazy loads at startup, in both TTY and pipe stdout modes. A non-fetch/stream/fs.promises/node:replprogram no longer pays parse cost for any of those subtrees.What's now lazy
Web platform (final commit)
The 208 KB
06_streams.jspolyfill and every ext module that pulls it:ReadableStream/WritableStream/TransformStreamand all their inner controllers/readers (13 stream classes)Request/Response/fetch/EventSource(chain through22_body.js→06_streams.js)caches/CacheStorage/CacheCompressionStream/DecompressionStreamnode:stream/webDeno.serve/Deno.serveHttp/Deno.upgradeWebSocket/Deno.Command/Deno.run/Deno.spawn*/Deno.kill/Deno.openKvNode polyfills (earlier in stack)
node:http/node:http2/node:https/node:_http_*/node:internal/http*node:crypto/node:internal/crypto/{cipher,hash,...}node:zlib,node:repl,node:internal/repl,node:readline,node:readline/promisesnode:child_process,node:internal/child_process,node:dgram,node:clusternode:tls,node:_tls_common,node:_tls_wrapnode:fs/promises,node:assert/strict,node:internal/event_target,node:internal/fs/utilsKept eager (loading them is on the hot path of every program):
node:stream,node:stream/promises,node:net,node:tty,node:module,node:process.Overview of changes
Infrastructure (
build(snapshot)+refactor(core))DENO_SNAPSHOT_IMPORT_GRAPH=<file>env var: dump JSONL of every esm/lazy-script edge during snapshot build. Used to identify exactly which scripts are dragging which polyfills into the snapshot.DENO_LOG_LAZY_LOAD=1runtime env var: prints a stderr line each time a lazy_loaded_esm / lazy_loaded_js entry actually parses at runtime. Cache hits suppressed.__bootstrapin01_core.jsso deferredloadExtScriptcalls still findcore/primordials/internalsafter99_main.jsdeletesglobalThis.__bootstrap..tstranspile inbuild.rs: pre-transpile anylazy_loaded_js/lazy_loaded_esmfile that wasn't consumed at snapshot time so the runtime loader receives parseable JS rather than TypeScript.module_map, if static-import resolve fails, fall back to the lazy ESM source list before erroring (letsnode:_http_*re-export work without eager registration).Bug fixes pulled out of the lazification work
fix(ext/node): deferlazyLoadProcess()todeprecated()wrapper to break theassert.ts ↔ process.tscycle exposed by lazification.fix(core): drop the module-map borrow before recursively re-evaluating a lazy ESM module — the prior code held it acrossmodule.evaluate(scope)and panicked onRefCell::borrow_mutduring recursive lazy_load_esm.Final commit — web-streams chain
runtime/js/98_global_scope_shared.js: converts every streams-pulling global topropNonEnumerableLazyLoaded/ wrapper-function form.runtime/js/99_main.js: stops spreadingdenoNswith{...denoNs}(which invokes every getter); usesObjectDefineProperties + getOwnPropertyDescriptorsinstead. Same fix for the unstable-feature merge loop. Wraps the wasm-streaming callback and defersregisterDeclarativeServerto theaddMainModuleHandlercallback.ext/web/13_message_port.js: drops top-level streams import;markNotSerializableregistration moved into06_streams.jsitself (inverts the dep so message_port no longer drags streams).ext/node/polyfills/01_require.js: lazifiesinternal/child_process(which pulled40_process.js → 22_body.js) andstream/web(which pulled14_compression.js).ext/node/polyfills/internal/streams/fast-utf8-stream.js: replacesimport * as fs from "node:fs"withcreateLazyLoader("node:fs"). The static import was re-enteringnode:fs's evaluating body and TDZ-trapping onlazyUtf8Stream().default.ext/node/polyfills/internal/fs/{handle,promises}.ts: defers every top-levelpromisify(lazyFs().X)to first-call wrappers. Same TDZ cycle:node:fs'sexport const promises = mod.promisesline re-triggersget promiseswhilelazyInternalPromises().defaultis still in TDZ.Outcome
deno run empty.jsstartup parsesimport 'node:crypto'costimport 'node:http'costfetch('...')first-call cost26_fetch.js+22_body.js+06_streams.json demandPrograms that don't touch streams/fetch/http/repl/Deno.serve no longer pay the parse cost.
Test plan
cargo testpassescargo test --test node_compatpasses (down from 43 → ~38 fails, the remainder are pre-existing on main:IO Safety violationinforkand the v8 weak-handle GC flake intest-repl-tab-complete-buffer)DENO_LOG_LAZY_LOAD=1 deno run empty.jsprints 0 lazy loads (TTY and pipe)Deno.serve,fetch,new ReadableStream/Request/Response,structuredClone(new ReadableStream())rejection,fs.promises.readdir/readFile,node:child_process.spawn,node:stream/web