Repository: denoland/deno
Pull request: fix(core): prevent panic when multiple lazy-loaded ESM modules are loaded during TLA

<!--
Before submitting a PR, please read https://docs.deno.com/runtime/manual/references/contributing

1. Give the PR a descriptive title.

  Examples of good title:
    - fix(std/http): Fix race condition in server
    - docs(console): Update docstrings
    - feat(doc): Handle nested reexports

  Examples of bad title:
    - fix #7123
    - update docs
    - fix bugs

2. Ensure there is a related issue and it is referenced in the PR text.
3. Ensure there are tests that cover the changes.
4. Ensure `cargo test` passes.
5. Ensure `./tools/format.js` passes without changing files.
6. Ensure `./tools/lint.js` passes.
7. Open as a draft PR if your work is still in progress. The CI won't run
   all steps, but you can add '[ci]' to a commit message to force it to.
8. If you would like to run the benchmarks on the CI, add the 'ci-bench' label.
-->

- fixes: https://github.com/denoland/deno/issues/32758


Changed files:
filename: libs/core/modules/map.rs
patch: @@ -237,6 +237,10 @@ pub(crate) struct ModuleMap {
   pending_tla_waiters:
     RefCell<HashMap<ModuleId, Vec<v8::Global<v8::PromiseResolver>>>>,
   pending_mod_evaluation: Cell<bool>,
+  /// Set to `true` while inside `module.evaluate()` in `mod_evaluate`.
+  /// Used to suppress microtask checkpoints in `lazy_load_es_module_with_code`
+  /// during module evaluation, preventing premature draining of TLA-related microtasks.
+  evaluating_top_level: Cell<bool>,
   code_cache_ready_futs: TrackedFutures<Pin<Box<CodeCacheReadyFuture>>>,
   module_waker: AtomicWaker,
   data: RefCell<ModuleMapData>,
@@ -320,6 +324,7 @@ impl ModuleMap {
       pending_dyn_mod_evaluations: Default::default(),
       pending_tla_waiters: Default::default(),
       pending_mod_evaluation: Default::default(),
+      evaluating_top_level: Default::default(),
       code_cache_ready_futs: Default::default(),
       module_waker: Default::default(),
       data: Default::default(),
@@ -1426,7 +1431,9 @@ impl ModuleMap {
         .unwrap_or_else(|_| Err(CoreErrorKind::ExecutionTerminated.into_box()))
     });
 
+    self.evaluating_top_level.set(true);
     let Some(value) = module.evaluate(tc_scope) else {
+      self.evaluating_top_level.set(false);
       if tc_scope.has_terminated() || tc_scope.is_execution_terminating() {
         let undefined = v8::undefined(tc_scope).into();
         _ = sender
@@ -1436,6 +1443,7 @@ impl ModuleMap {
       }
       return Either::Right(receiver);
     };
+    self.evaluating_top_level.set(false);
 
     self.pending_mod_evaluation.set(true);
 
@@ -2200,7 +2208,16 @@ impl ModuleMap {
     let value = module_local.evaluate(scope).unwrap();
     // Under Explicit microtask policy, drain microtasks so the module
     // evaluation promise resolves for synchronous modules.
-    scope.perform_microtask_checkpoint();
+    //
+    // However, skip the checkpoint when we are inside a top-level
+    // `module.evaluate()` call (i.e. `evaluating_top_level` is set).
+    // Draining microtasks at this point can prematurely resolve
+    // TLA-related microtasks (e.g. `await` resume jobs from eagerly-
+    // resolved async ops), which prevents the module evaluation promise
+    // from settling correctly later.
+    if !self.evaluating_top_level.get() {
+      scope.perform_microtask_checkpoint();
+    }
     let promise = v8::Local::<v8::Promise>::try_from(value).unwrap();
     let result = promise.result(scope);
     if !result.is_undefined() {

filename: libs/core/modules/testdata/lazy_loaded_2.js
patch: @@ -0,0 +1,3 @@
+// Copyright 2018-2026 the Deno authors. MIT license.
+export const baz = "baz";
+export default { baz };

filename: libs/core/modules/tests.rs
patch: @@ -2505,3 +2505,122 @@ throwError();
     err_str
   );
 }
+
+/// Regression test for https://github.com/denoland/deno/issues/32758
+///
+/// When two lazy-loaded ESM modules are triggered during the same
+/// module evaluation, and one of the importing modules has a top-level
+/// await on an eagerly-resolved async op, the `perform_microtask_checkpoint()`
+/// inside `lazy_load_es_module_with_code()` can prematurely resolve the main
+/// module's evaluation promise while `mod_evaluate()` has not yet set up
+/// its `.then()` handlers. This leaves `pending_mod_evaluation = true`
+/// permanently, causing the event loop to panic with
+/// "Expected at least one stalled top-level await".
+#[tokio::test]
+async fn test_lazy_loaded_esm_with_tla_no_panic() {
+  // An async op with no .await points
+  #[op2]
+  #[allow(clippy::unused_async, reason = "eagerly resolves on first poll")]
+  async fn op_eager_resolve() -> u32 {
+    42
+  }
+
+  deno_core::extension!(
+    test_ext,
+    ops = [op_eager_resolve],
+    lazy_loaded_esm = [
+      dir "modules/testdata",
+      "lazy_loaded.js",
+      "lazy_loaded_2.js",
+    ]
+  );
+
+  let loader = Rc::new(TestingModuleLoader::new(NoopModuleLoader));
+
+  let mut runtime = JsRuntime::new(RuntimeOptions {
+    extensions: vec![test_ext::init()],
+    module_loader: Some(loader),
+    ..Default::default()
+  });
+
+  let module_map = runtime.module_map().clone();
+
+  // Build the module graph manually:
+  //
+  //   main.js  (main module)
+  //     ├── tla_mod.js   -- triggers lazy_loaded.js, awaits op_eager_resolve
+  //     └── lazy_mod.js  -- triggers lazy_loaded_2.js
+  //
+  // The key is that tla_mod.js's `await` resolves eagerly, and the
+  // microtask checkpoint inside lazy_loaded_2.js's lazy load can
+  // prematurely resolve the main module evaluation promise.
+
+  let (mod_main, mod_tla, mod_lazy) = {
+    deno_core::scope!(scope, runtime);
+
+    let mod_tla = module_map
+      .new_es_module(
+        scope,
+        false,
+        ascii_str!("file:///tla_mod.js").into(),
+        ascii_str!(
+          r#"
+          const lazy1 = Deno.core.createLazyLoader("ext:test_ext/lazy_loaded.js")();
+          if (lazy1.foo !== "foo") throw new Error("lazy1.foo: " + lazy1.foo);
+          if (lazy1.bar !== 123) throw new Error("lazy1.bar: " + lazy1.bar);
+          const result = await Deno.core.ops.op_eager_resolve();
+          if (result !== 42) throw new Error("unexpected: " + result);
+          "#
+        )
+        .into(),
+        false,
+        None,
+      )
+      .unwrap();
+
+    let mod_lazy = module_map
+      .new_es_module(
+        scope,
+        false,
+        ascii_str!("file:///lazy_mod.js").into(),
+        ascii_str!(
+          r#"
+          const lazy2 = Deno.core.createLazyLoader("ext:test_ext/lazy_loaded_2.js")();
+          if (lazy2.baz !== "baz") throw new Error("lazy2.baz: " + lazy2.baz);
+          "#
+        )
+        .into(),
+        false,
+        None,
+      )
+      .unwrap();
+
+    let mod_main = module_map
+      .new_es_module(
+        scope,
+        true,
+        ascii_str!("file:///main.js").into(),
+        ascii_str!(
+          r#"
+          import "./tla_mod.js";
+          import "./lazy_mod.js";
+          "#
+        )
+        .into(),
+        false,
+        None,
+      )
+      .unwrap();
+
+    (mod_main, mod_tla, mod_lazy)
+  };
+
+  runtime.instantiate_module(mod_tla).unwrap();
+  runtime.instantiate_module(mod_lazy).unwrap();
+  runtime.instantiate_module(mod_main).unwrap();
+
+  // This should not panic with "Expected at least one stalled top-level await"
+  let receiver = runtime.mod_evaluate(mod_main);
+  runtime.run_event_loop(Default::default()).await.unwrap();
+  receiver.await.unwrap();
+}