Repository: denoland/deno_core
Pull request #520 (2024-01-30 02:32:09)

feat: Add property definition utilities to Deno.core
These are common utilities that are duplicated several times in `deno` and Deploy.

bartlomieju: @crowlKats requested it

Patch:
diff --git a/core/01_core.js b/core/01_core.js
index 37d69d8..285b3bb 100644
--- a/core/01_core.js
+++ b/core/01_core.js
@@ -405,6 +405,7 @@
     op_get_promise_details,
     op_get_proxy_details,
     op_has_tick_scheduled,
+    op_lazy_load_esm,
     op_memory_usage,
     op_op_names,
     op_panic,
@@ -455,6 +456,99 @@
     op_is_weak_set,
   } = ensureFastOps();
 
+  function propWritable(value) {
+    return {
+      value,
+      writable: true,
+      enumerable: true,
+      configurable: true,
+    };
+  }
+
+  function propNonEnumerable(value) {
+    return {
+      value,
+      writable: true,
+      enumerable: false,
+      configurable: true,
+    };
+  }
+
+  function propReadOnly(value) {
+    return {
+      value,
+      enumerable: true,
+      writable: false,
+      configurable: true,
+    };
+  }
+
+  function propGetterOnly(getter) {
+    return {
+      get: getter,
+      set() {},
+      enumerable: true,
+      configurable: true,
+    };
+  }
+
+  function propWritableLazyLoaded(getter, loadFn) {
+    let valueIsSet = false;
+    let value;
+
+    return {
+      get() {
+        const loadedValue = loadFn();
+        if (valueIsSet) {
+          return value;
+        } else {
+          return getter(loadedValue);
+        }
+      },
+      set(v) {
+        loadFn();
+        valueIsSet = true;
+        value = v;
+      },
+      enumerable: true,
+      configurable: true,
+    };
+  }
+
+  function propNonEnumerableLazyLoaded(getter, loadFn) {
+    let valueIsSet = false;
+    let value;
+
+    return {
+      get() {
+        const loadedValue = loadFn();
+        if (valueIsSet) {
+          return value;
+        } else {
+          return getter(loadedValue);
+        }
+      },
+      set(v) {
+        loadFn();
+        valueIsSet = true;
+        value = v;
+      },
+      enumerable: false,
+      configurable: true,
+    };
+  }
+
+  function createLazyLoader(specifier) {
+    let value;
+
+    return function lazyLoad() {
+      if (!value) {
+        value = op_lazy_load_esm(specifier);
+      }
+      return value;
+    };
+  }
+
   // Extra Deno.core.* exports
   const core = ObjectAssign(globalThis.Deno.core, {
     internalRidSymbol: Symbol("Deno.internal.rid"),
@@ -554,6 +648,13 @@
     currentUserCallSite,
     wrapConsole,
     v8Console,
+    propReadOnly,
+    propWritable,
+    propNonEnumerable,
+    propGetterOnly,
+    propWritableLazyLoaded,
+    propNonEnumerableLazyLoaded,
+    createLazyLoader,
   });
 
   const internals = {};
diff --git a/core/Cargo.toml b/core/Cargo.toml
index dbe897d..cb9faf3 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -10,6 +10,10 @@ readme = "README.md"
 repository.workspace = true
 description = "A modern JavaScript/TypeScript runtime built with V8, Rust, and Tokio"
 
+[[bin]]
+name = "dcore"
+path = "main.rs"
+
 [lib]
 path = "lib.rs"
 
@@ -32,9 +36,9 @@ bit-set.workspace = true
 bit-vec.workspace = true
 bytes.workspace = true
 cooked-waker.workspace = true
+deno_core_icudata = { workspace = true, optional = true }
 deno_ops.workspace = true
 deno_unsync.workspace = true
-deno_core_icudata = { workspace = true, optional = true }
 futures.workspace = true
 libc.workspace = true
 log.workspace = true
@@ -52,11 +56,11 @@ url.workspace = true
 v8.workspace = true
 
 [dev-dependencies]
-deno_ast.workspace = true
 bencher.workspace = true
+deno_ast.workspace = true
+fastrand.workspace = true
 pretty_assertions.workspace = true
 rstest.workspace = true
-fastrand.workspace = true
 unicycle = "0"
 
 [[bench]]
diff --git a/core/cppgc.rs b/core/cppgc.rs
index ccdace1..a6fec26 100644
--- a/core/cppgc.rs
+++ b/core/cppgc.rs
@@ -18,7 +18,7 @@ const SLOT_OFFSET: i32 = 1;
 const FIELD_COUNT: usize = 2;
 
 pub fn make_cppgc_object<'a, T: 'static>(
-  scope: &'a mut v8::HandleScope,
+  scope: &mut v8::HandleScope<'a>,
   t: T,
 ) -> v8::Local<'a, v8::Object> {
   let templ = v8::ObjectTemplate::new(scope);
diff --git a/core/lib.deno_core.d.ts b/core/lib.deno_core.d.ts
index fa2bcac..45e60e0 100644
--- a/core/lib.deno_core.d.ts
+++ b/core/lib.deno_core.d.ts
@@ -299,6 +299,23 @@ declare namespace Deno {
     function isWeakMap(value: unknown): value is WeakMap<WeakKey, unknown>;
     function isWeakSet(value: unknown): value is WeakSet<WeakKey>;
 
+    function propWritable(value: unknown): PropertyDescriptor;
+    function propNonEnumerable(value: unknown): PropertyDescriptor;
+    function propReadOnly(value: unknown): PropertyDescriptor;
+    function propGetterOnly(value: unknown): PropertyDescriptor;
+
+    function propWritableLazyLoaded<T>(
+      getter: (loadedValue: T) => unknown,
+      loadFn: LazyLoader<T>,
+    ): PropertyDescriptor;
+    function propNonEnumerableLazyLoaded<T>(
+      getter: (loadedValue: T) => unknown,
+      loadFn: LazyLoader<T>,
+    ): PropertyDescriptor;
+
+    type LazyLoader<T> = () => T;
+    function createLazyLoader<T = unknown>(specifier: string): LazyLoader<T>;
+
     const build: {
       target: string;
       arch: string;
diff --git a/core/examples/fs_module_loader.rs b/core/main.rs
similarity index 95%
rename from core/examples/fs_module_loader.rs
rename to core/main.rs
index 959c037..7848875 100644
--- a/core/examples/fs_module_loader.rs
+++ b/core/main.rs
@@ -72,8 +72,11 @@ fn text_module(
 
 fn main() -> Result<(), Error> {
   let args: Vec<String> = std::env::args().collect();
+  eprintln!(
+    "🛑 deno_core binary is meant for development and testing purposes."
+  );
   if args.len() < 2 {
-    println!("Usage: target/examples/debug/fs_module_loader <path_to_module>");
+    println!("Usage: cargo run -- <path_to_module>");
     std::process::exit(1);
   }
   let main_url = &args[1];
diff --git a/core/modules/map.rs b/core/modules/map.rs
index bd0413e..aab8fa7 100644
--- a/core/modules/map.rs
+++ b/core/modules/map.rs
@@ -381,6 +381,8 @@ impl ModuleMap {
     Ok(module_id)
   }
 
+  // TODO(bartlomieju): this method should instantiate the module - these have
+  // no dependencies so can be instantiated immediately.
   /// Creates a "synthetic module", that contains only a single, "default" export,
   /// that returns the provided value.
   pub fn new_synthetic_module(
diff --git a/core/runtime/jsrealm.rs b/core/runtime/jsrealm.rs
index 118ec09..fb0f00d 100644
--- a/core/runtime/jsrealm.rs
+++ b/core/runtime/jsrealm.rs
@@ -412,6 +412,9 @@ impl JsRealm {
   ///
   /// User must call [`ModuleMap::mod_evaluate`] with returned `ModuleId`
   /// manually after load is finished.
+  // TODO(bartlomieju): create a separate method to execute code synchronously
+  // from a loader? Would simplify JsRuntime code and not require running in
+  // a `block_on`.
   pub(crate) async fn load_side_module(
     &self,
     isolate: &mut v8::Isolate,
diff --git a/core/runtime/jsruntime.rs b/core/runtime/jsruntime.rs
index 7da3e5d..9196b8a 100644
--- a/core/runtime/jsruntime.rs
+++ b/core/runtime/jsruntime.rs
@@ -309,7 +309,6 @@ pub struct JsRuntime {
   // [`JsRuntime::init_extension_js`]. This field is populated only if a
   // snapshot is being created.
   files_loaded_from_fs_during_snapshot: Vec<&'static str>,
-  init_mode: InitMode,
   // Marks if this is considered the top-level runtime. Used only by inspector.
   is_main_runtime: bool,
 }
@@ -598,20 +597,14 @@ impl JsRuntime {
     will_snapshot: bool,
   ) -> Result<JsRuntime, Error> {
     let init_mode = InitMode::from_options(&options);
-    let mut op_state = OpState::new(options.feature_checker.take());
-
     let mut extensions = std::mem::take(&mut options.extensions);
-    let op_decls =
-      extension_set::init_ops(crate::ops_builtin::BUILTIN_OPS, &mut extensions);
-    extension_set::setup_op_state(&mut op_state, &mut extensions);
-    let (
-      global_template_middleware,
-      global_object_middlewares,
-      additional_references,
-    ) = extension_set::get_middlewares_and_external_refs(&mut extensions);
 
-    let isolate_ptr = setup::create_isolate_ptr();
+    // First let's create an `OpState` and contribute to it from extensions...
+    let mut op_state = OpState::new(options.feature_checker.take());
+    extension_set::setup_op_state(&mut op_state, &mut extensions);
 
+    // ...now let's set up ` JsRuntimeState`, we'll need to set some fields
+    // later, after `JsRuntime` is all set up...
     let waker = op_state.waker.clone();
     let op_state = Rc::new(RefCell::new(op_state));
     let state_rc = Rc::new(JsRuntimeState {
@@ -630,6 +623,11 @@ impl JsRuntime {
       has_inspector: false.into(),
     });
 
+    // ...now we're moving on to ops; set them up, create `OpCtx` for each op
+    // and get ready to actually create V8 isolate...
+    let op_decls =
+      extension_set::init_ops(crate::ops_builtin::BUILTIN_OPS, &mut extensions);
+
     let op_driver = Rc::new(OpDriverImpl::default());
     let op_metrics_factory_fn = options.op_metrics_factory_fn.take();
     let get_error_class_fn = options.get_error_class_fn.unwrap_or(&|_| "Error");
@@ -643,6 +641,12 @@ impl JsRuntime {
       get_error_class_fn,
     );
 
+    // ...ops are now almost fully set up; let's create a V8 isolate...
+    let (
+      global_template_middleware,
+      global_object_middlewares,
+      additional_references,
+    ) = extension_set::get_middlewares_and_external_refs(&mut extensions);
     let external_refs =
       bindings::create_external_references(&op_ctxs, &additional_references);
 
@@ -655,39 +659,31 @@ impl JsRuntime {
 
     let cpp_heap = setup::init_cppgc(&mut isolate);
 
+    // ...isolate is fully set up, we can forward its pointer to the ops to finish
+    // their' setup...
     for op_ctx in op_ctxs.iter_mut() {
       op_ctx.isolate = isolate.as_mut() as *mut Isolate;
     }
 
-    let context_state = Rc::new(ContextState::new(
-      op_driver.clone(),
-      isolate_ptr,
-      options.get_error_class_fn.unwrap_or(&|_| "Error"),
-      op_ctxs,
-    ));
-
-    // TODO(bartlomieju): factor out
-    // Add the task spawners to the OpState
-    let spawner = context_state
-      .task_spawner_factory
-      .clone()
-      .new_same_thread_spawner();
-    op_state.borrow_mut().put(spawner);
-    let spawner = context_state
-      .task_spawner_factory
-      .clone()
-      .new_cross_thread_spawner();
-    op_state.borrow_mut().put(spawner);
-
+    // TODO(Bartlomieju): this can be simplified
+    let isolate_ptr = setup::create_isolate_ptr();
     // SAFETY: this is first use of `isolate_ptr` so we are sure we're
     // not overwriting an existing pointer.
     isolate = unsafe {
       isolate_ptr.write(isolate);
       isolate_ptr.read()
     };
-
     op_state.borrow_mut().put(isolate_ptr);
 
+    // ...once ops and isolate are set up, we can create a `ContextState`...
+    let context_state = Rc::new(ContextState::new(
+      op_driver.clone(),
+      isolate_ptr,
+      options.get_error_class_fn.unwrap_or(&|_| "Error"),
+      op_ctxs,
+    ));
+
+    // TODO(bartlomieju): factor out
     // Add the task spawners to the OpState
     let spawner = context_state
       .task_spawner_factory
@@ -700,8 +696,7 @@ impl JsRuntime {
       .new_cross_thread_spawner();
     op_state.borrow_mut().put(spawner);
 
-    // TODO(bartlomieju): this context creation and setup is really non-trivial.
-    // Additionally it's unclear what is done when snapshotting/running from snapshot.
+    // ...and with `ContextState` available we can set up V8 context...
     let mut snapshotted_data = None;
     let main_context = {
       let scope = &mut v8::HandleScope::new(&mut isolate);
@@ -726,6 +721,8 @@ impl JsRuntime {
     let scope = &mut context_scope;
     let context = v8::Local::new(scope, &main_context);
 
+    // ...followed by creation of `Deno.core` namespace, as well as internal
+    // infrastructure to provide JavaScript bindings for ops...
     if init_mode == InitMode::New {
       bindings::initialize_deno_core_namespace(scope, context, init_mode);
       bindings::initialize_primordials_and_infra(scope)?;
@@ -748,6 +745,9 @@ impl JsRuntime {
       None
     };
 
+    // ...now that JavaScript bindings to ops are available we can deserialize
+    // modules stored in the snapshot (because they depend on the ops and external
+    // references must match properly) and recreate a module map...
     let loader = options
       .module_loader
       .unwrap_or_else(|| Rc::new(NoopModuleLoader));
@@ -755,7 +755,6 @@ impl JsRuntime {
       .import_meta_resolve_callback
       .unwrap_or_else(|| Box::new(default_import_meta_resolve_cb));
     let exception_state = context_state.exception_state.clone();
-
     let module_map = Rc::new(ModuleMap::new(
       loader,
       exception_state.clone(),
@@ -775,6 +774,7 @@ impl JsRuntime {
 
     context.set_slot(scope, module_map.clone());
 
+    // ...we are ready to create a "realm" for the context...
     let main_realm = {
       let main_realm =
         JsRealmInner::new(context_state, main_context, module_map.clone());
@@ -791,6 +791,7 @@ impl JsRuntime {
 
     drop(context_scope);
 
+    // ...which allows us to create the `JsRuntime` instance...
     let mut js_runtime = JsRuntime {
       inner: InnerIsolateState {
         will_snapshot,
@@ -799,20 +800,57 @@ impl JsRuntime {
         v8_isolate: ManuallyDrop::new(isolate),
         cpp_heap: ManuallyDrop::new(cpp_heap),
       },
-      init_mode,
       allocations: IsolateAllocations::default(),
       files_loaded_from_fs_during_snapshot: vec![],
       is_main_runtime: options.is_main,
     };
 
-    let files_loaded_from_fs_during_snapshot =
-      js_runtime.init_extension_js(extensions)?;
+    let mut files_loaded = Vec::with_capacity(128);
+
+    // ...we're almost done with the setup, all that's left is to execute
+    // internal JS and then execute code provided by extensions...
+    {
+      let realm = JsRealm::clone(&js_runtime.inner.main_realm);
+      let context_global = realm.context();
+      let module_map = realm.0.module_map();
+
+      // TODO(bartlomieju): this is somewhat duplicated in `bindings::initialize_context`,
+      // but for migration period we need to have ops available in both `Deno.core.ops`
+      // as well as have them available in "virtual ops module"
+      // if !matches!(
+      //   self.init_mode,
+      //   InitMode::FromSnapshot {
+      //     skip_op_registration: true
+      //   }
+      // ) {
+      if init_mode == InitMode::New {
+        js_runtime
+          .execute_virtual_ops_module(context_global, module_map.clone());
+      }
+
+      if init_mode == InitMode::New {
+        js_runtime.execute_builtin_sources(
+          &realm,
+          &module_map,
+          &mut files_loaded,
+        )?;
+      }
+
+      js_runtime.store_js_callbacks(&realm);
+
+      js_runtime.init_extension_js(
+        &realm,
+        &module_map,
+        extensions,
+        &mut files_loaded,
+      )?;
+    }
 
     if will_snapshot {
-      js_runtime.files_loaded_from_fs_during_snapshot =
-        files_loaded_from_fs_during_snapshot;
+      js_runtime.files_loaded_from_fs_during_snapshot = files_loaded;
     }
 
+    // ...and we've made it; `JsRuntime` is ready to execute user code.
     Ok(js_runtime)
   }
 
@@ -866,6 +904,8 @@ impl JsRuntime {
     }
   }
 
+  // TODO(bartlomieju): remove, instead `JsRuntimeForSnapshot::new` should return
+  // a struct that contains this data.
   pub(crate) fn files_loaded_from_fs_during_snapshot(&self) -> &[&'static str] {
     &self.files_loaded_from_fs_during_snapshot
   }
@@ -956,115 +996,106 @@ impl JsRuntime {
   }
 
   /// Initializes JS of provided Extensions in the given realm.
-  fn init_extension_js(
+  async fn init_extension_js_inner(
     &mut self,
+    realm: &JsRealm,
+    module_map: &Rc<ModuleMap>,
     extensions: Vec<Extension>,
-  ) -> Result<Vec<&'static str>, Error> {
+    files_loaded: &mut Vec<&'static str>,
+  ) -> Result<(), Error> {
     // Initialization of JS happens in phases:
     // 1. Iterate through all extensions:
     //  a. Execute all extension "script" JS files
     //  b. Load all extension "module" JS files (but do not execute them yet)
-    // 2. Iterate through all extensions:
-    //  a. If an extension has a `esm_entry_point`, execute it.
-    let mut files_loaded = Vec::with_capacity(128);
-    let realm = JsRealm::clone(&self.inner.main_realm);
-    let context_global = realm.context();
-    let module_map = realm.0.module_map();
-
-    // TODO(bartlomieju): hoist it out of this method.
-    // TODO(bartlomieju): this is somewhat duplicated in `bindings::initialize_context`,
-    // but for migration period we need to have ops available in both `Deno.core.ops`
-    // as well as have them available in "virtual ops module"
-    // if !matches!(
-    //   self.init_mode,
-    //   InitMode::FromSnapshot {
-    //     skip_op_registration: true
-    //   }
-    // ) {
-    if self.init_mode == InitMode::New {
-      self.execute_virtual_ops_module(context_global, module_map.clone());
-    }
-
-    // TODO(bartlomieju): hoist it out of this method.
-    if self.init_mode == InitMode::New {
-      self.execute_builtin_sources(&realm, &module_map, &mut files_loaded)?;
-    }
-
-    // TODO(bartlomieju): hoist it out of this method.
-    self.init_cbs(&realm);
-
-    let loader = module_map.loader.borrow().clone();
-    let ext_loader = Rc::new(ExtModuleLoader::new(&extensions));
-    *module_map.loader.borrow_mut() = ext_loader;
+    //  c. If extension has an "entrypoint" collect it
+    // 2. Execute all collected entrypoints.
+    // 3. Verify that all extension files have been executed.
 
     let mut esm_entrypoints = vec![];
 
-    futures::executor::block_on(async {
-      for extension in extensions {
-        // If the extension provides "lazy loaded ES modules" then store them
-        // on the ModuleMap.
-        module_map
-          .add_lazy_loaded_esm_sources(extension.get_lazy_loaded_esm_sources());
-
-        let maybe_esm_entry_point = extension.get_esm_entry_point();
-
-        for file_source in extension.get_esm_sources() {
-          mark_as_loaded_from_fs_during_snapshot(
-            &mut files_loaded,
-            &file_source.code,
-          );
-          realm
-            .load_side_module(
-              self.v8_isolate(),
-              &ModuleSpecifier::parse(file_source.specifier)?,
-              None,
-            )
-            .await?;
-        }
+    for extension in extensions {
+      // If the extension provides "lazy loaded ES modules" then store them
+      // on the ModuleMap.
+      module_map
+        .add_lazy_loaded_esm_sources(extension.get_lazy_loaded_esm_sources());
 
-        if let Some(entry_point) = maybe_esm_entry_point {
-          esm_entrypoints.push(entry_point);
-        }
+      let maybe_esm_entry_point = extension.get_esm_entry_point();
 
-        for file_source in extension.get_js_sources() {
-          mark_as_loaded_from_fs_during_snapshot(
-            &mut files_loaded,
-            &file_source.code,
-          );
-          realm.execute_script(
+      for file_source in extension.get_esm_sources() {
+        mark_as_loaded_from_fs_during_snapshot(files_loaded, &file_source.code);
+        realm
+          .load_side_module(
             self.v8_isolate(),
-            file_source.specifier,
-            file_source.load()?,
-          )?;
-        }
+            &ModuleSpecifier::parse(file_source.specifier)?,
+            None,
+          )
+          .await?;
       }
 
-      // ...then execute all entry points
-      for specifier in esm_entrypoints {
-        let Some(mod_id) =
-          module_map.get_id(specifier, RequestedModuleType::None)
-        else {
-          bail!("{} not present in the module map", specifier);
-        };
-
-        let isolate = self.v8_isolate();
-        let scope = &mut realm.handle_scope(isolate);
-        module_map.mod_evaluate_sync(scope, mod_id)?;
+      if let Some(entry_point) = maybe_esm_entry_point {
+        esm_entrypoints.push(entry_point);
       }
 
-      #[cfg(debug_assertions)]
-      {
-        let mut scope = realm.handle_scope(self.v8_isolate());
-        module_map.check_all_modules_evaluated(&mut scope)?;
+      for file_source in extension.get_js_sources() {
+        mark_as_loaded_from_fs_during_snapshot(files_loaded, &file_source.code);
+        realm.execute_script(
+          self.v8_isolate(),
+          file_source.specifier,
+          file_source.load()?,
+        )?;
       }
+    }
+
+    // ...then execute all entry points
+    for specifier in esm_entrypoints {
+      let Some(mod_id) =
+        module_map.get_id(specifier, RequestedModuleType::None)
+      else {
+        bail!("{} not present in the module map", specifier);
+      };
 
-      Ok::<_, anyhow::Error>(())
-    })?;
+      let isolate = self.v8_isolate();
+      let scope = &mut realm.handle_scope(isolate);
+      module_map.mod_evaluate_sync(scope, mod_id)?;
+    }
+
+    Ok(())
+  }
+
+  /// Initializes JS of provided Extensions in the given realm.
+  fn init_extension_js(
+    &mut self,
+    realm: &JsRealm,
+    module_map: &Rc<ModuleMap>,
+    extensions: Vec<Extension>,
+    files_loaded: &mut Vec<&'static str>,
+  ) -> Result<(), Error> {
+    // Temporarily override the loader of the `ModuleMap` so we can load
+    // extension code.
+
+    // TODO(bartlomieju): maybe this should be a method on the `ModuleMap`,
+    // instead of explicitly changing the `.loader` field?
+    let loader = module_map.loader.borrow().clone();
+    let ext_loader = Rc::new(ExtModuleLoader::new(&extensions));
+    *module_map.loader.borrow_mut() = ext_loader;
+
+    futures::executor::block_on(self.init_extension_js_inner(
+      realm,
+      module_map,
+      extensions,
+      files_loaded,
+    ))?;
+
+    #[cfg(debug_assertions)]
+    {
+      let mut scope = realm.handle_scope(self.v8_isolate());
+      module_map.check_all_modules_evaluated(&mut scope)?;
+    }
 
     let module_map = realm.0.module_map();
     *module_map.loader.borrow_mut() = loader;
 
-    Ok(files_loaded)
+    Ok(())
   }
 
   pub fn eval<'s, T>(
@@ -1081,8 +1112,9 @@ impl JsRuntime {
     scope.escape(v).try_into().ok()
   }
 
-  /// Grabs a reference to core.js' eventLoopTick & buildCustomError
-  fn init_cbs(&mut self, realm: &JsRealm) {
+  /// Grab and store JavaScript bindings to callbacks necessary for the
+  /// JsRuntime to operate properly.
+  fn store_js_callbacks(&mut self, realm: &JsRealm) {
     let (event_loop_tick_cb, build_custom_error_cb) = {
       let scope = &mut realm.handle_scope(self.v8_isolate());
       let context = realm.context();


Test patch:
diff --git a/core/modules/tests.rs b/core/modules/tests.rs
index 83b6dee..b18a236 100644
--- a/core/modules/tests.rs
+++ b/core/modules/tests.rs
@@ -431,10 +431,10 @@ fn test_lazy_loaded_esm() {
       "setup.js",
       r#"
       Deno.core.print("1\n");
-      const module = Deno.core.ops.op_lazy_load_esm("ext:test_ext/lazy_loaded.js");
+      const module = Deno.core.createLazyLoader("ext:test_ext/lazy_loaded.js")();
       module.blah("hello\n");
       Deno.core.print(`${JSON.stringify(module)}\n`);
-      const module1 = Deno.core.ops.op_lazy_load_esm("ext:test_ext/lazy_loaded.js");
+      const module1 = Deno.core.createLazyLoader("ext:test_ext/lazy_loaded.js")();
       if (module !== module1) throw new Error("should return the same error");
       "#,
     )
diff --git a/testing/checkin/runner/mod.rs b/testing/checkin/runner/mod.rs
index dc1eab4..f6267ed 100644
--- a/testing/checkin/runner/mod.rs
+++ b/testing/checkin/runner/mod.rs
@@ -16,12 +16,16 @@ use tokio::io::AsyncReadExt;
 
 use crate::checkin::runner::testing::TestData;
 
+use self::ops_worker::worker_create;
+use self::ops_worker::WorkerCloseWatcher;
+use self::ops_worker::WorkerHostSide;
 use self::testing::TestFunctions;
 
 mod ops;
 mod ops_async;
 mod ops_buffer;
 mod ops_io;
+mod ops_worker;
 mod testing;
 mod ts_module_loader;
 
@@ -44,6 +48,12 @@ deno_core::extension!(
     ops_async::op_async_throw_error_deferred,
     ops_buffer::op_v8slice_store,
     ops_buffer::op_v8slice_clone,
+    ops_worker::op_worker_spawn,
+    ops_worker::op_worker_send,
+    ops_worker::op_worker_recv,
+    ops_worker::op_worker_parent,
+    ops_worker::op_worker_await_close,
+    ops_worker::op_worker_terminate,
   ],
   esm_entry_point = "ext:checkin_runtime/__init.js",
   esm = [
@@ -54,15 +64,19 @@ deno_core::extension!(
     "console.ts" with_specifier "checkin:console",
     "testing.ts" with_specifier "checkin:testing",
     "timers.ts" with_specifier "checkin:timers",
+    "worker.ts" with_specifier "checkin:worker",
   ],
   state = |state| {
     state.put(TestFunctions::default());
     state.put(TestData::default());
-    state.put(Output::default());
   }
 );
 
-fn create_runtime() -> JsRuntime {
+fn create_runtime(
+  output: Output,
+  parent: Option<WorkerCloseWatcher>,
+) -> (JsRuntime, WorkerHostSide) {
+  let (worker, worker_host_side) = worker_create(parent);
   let mut extensions = vec![checkin_runtime::init_ops_and_esm()];
 
   for extension in &mut extensions {
@@ -88,13 +102,15 @@ fn create_runtime() -> JsRuntime {
   });
   let stats = runtime.runtime_activity_stats_factory();
   runtime.op_state().borrow_mut().put(stats);
-  runtime
+  runtime.op_state().borrow_mut().put(worker);
+  runtime.op_state().borrow_mut().put(output);
+  (runtime, worker_host_side)
 }
 
 /// Run a integration test within the `checkin` runtime. This executes a single file, imports and all,
 /// and compares its output with the `.out` file in the same directory.
 pub fn run_integration_test(test: &str) {
-  let runtime = create_runtime();
+  let (runtime, _) = create_runtime(Output::default(), None);
   let tokio = tokio::runtime::Builder::new_current_thread()
     .enable_all()
     .build()
@@ -117,21 +133,22 @@ async fn run_integration_test_task(
     .run_event_loop(PollEventLoopOptions::default())
     .await
   {
+    let state = runtime.op_state().clone();
+    let state = state.borrow();
+    let output: &Output = state.borrow();
     for line in e.to_string().split('\n') {
-      actual_output += "[ERR] ";
-      actual_output += line;
-      actual_output += "\n";
+      output.line(format!("[ERR] {line}"));
     }
   }
   f.await?;
-  let mut output: Output = runtime.op_state().borrow_mut().take();
-  output.lines.push(String::new());
+  let mut lines = runtime.op_state().borrow_mut().take::<Output>().take();
+  lines.push(String::new());
   let mut expected_output = String::new();
   File::open(test_dir.join(format!("{test}.out")))
     .await?
     .read_to_string(&mut expected_output)
     .await?;
-  actual_output += &output.lines.join("\n");
+  actual_output += &lines.join("\n");
   assert_eq!(actual_output, expected_output);
   Ok(())
 }
@@ -139,7 +156,7 @@ async fn run_integration_test_task(
 /// Run a unit test within the `checkin` runtime. This loads a file which registers a number of tests,
 /// then each test is run individually and failures are printed.
 pub fn run_unit_test(test: &str) {
-  let runtime = create_runtime();
+  let (runtime, _) = create_runtime(Output::default(), None);
   let tokio = tokio::runtime::Builder::new_current_thread()
     .enable_all()
     .build()
diff --git a/testing/checkin/runner/ops.rs b/testing/checkin/runner/ops.rs
index 5acd4f2..8c76fe1 100644
--- a/testing/checkin/runner/ops.rs
+++ b/testing/checkin/runner/ops.rs
@@ -23,7 +23,7 @@ pub fn op_log_debug(#[string] s: &str) {
 #[op2(fast)]
 pub fn op_log_info(#[state] output: &mut Output, #[string] s: String) {
   println!("{s}");
-  output.lines.push(s);
+  output.line(s);
 }
 
 #[op2]
diff --git a/testing/checkin/runner/ops_worker.rs b/testing/checkin/runner/ops_worker.rs
new file mode 100644
index 0000000..7c3fa47
--- /dev/null
+++ b/testing/checkin/runner/ops_worker.rs
@@ -0,0 +1,224 @@
+// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
+use anyhow::anyhow;
+use anyhow::bail;
+use anyhow::Error;
+use deno_core::cppgc;
+use deno_core::op2;
+use deno_core::url::Url;
+use deno_core::v8;
+use deno_core::v8::IsolateHandle;
+use deno_core::JsRuntime;
+use deno_core::OpState;
+use deno_core::PollEventLoopOptions;
+use std::cell::RefCell;
+use std::future::poll_fn;
+use std::rc::Rc;
+use std::sync::mpsc::channel;
+use std::sync::Arc;
+use std::task::Poll;
+use tokio::sync::mpsc::unbounded_channel;
+use tokio::sync::mpsc::UnboundedReceiver;
+use tokio::sync::mpsc::UnboundedSender;
+use tokio::sync::watch;
+use tokio::sync::Mutex;
+
+use super::create_runtime;
+use super::testing::Output;
+
+/// Our cppgc object.
+pub struct WorkerControl {
+  worker_channel: WorkerChannel,
+  close_watcher: WorkerCloseWatcher,
+  handle: Option<IsolateHandle>,
+  shutdown_flag: Option<UnboundedSender<()>>,
+}
+
+pub struct WorkerChannel {
+  tx: UnboundedSender<String>,
+  rx: Mutex<UnboundedReceiver<String>>,
+}
+
+#[derive(Clone)]
+pub struct WorkerCloseWatcher {
+  watcher: Arc<Mutex<watch::Receiver<bool>>>,
+}
+
+pub struct Worker {
+  _close_send: watch::Sender<bool>,
+  pub(crate) close_watcher: WorkerCloseWatcher,
+  parent_channel: std::sync::Mutex<Option<WorkerChannel>>,
+  parent_close_watcher: std::sync::Mutex<Option<WorkerCloseWatcher>>,
+}
+
+pub struct WorkerHostSide {
+  worker_channel: WorkerChannel,
+  close_watcher: WorkerCloseWatcher,
+}
+
+pub fn worker_create(
+  parent: Option<WorkerCloseWatcher>,
+) -> (Worker, WorkerHostSide) {
+  let (tx1, rx1) = unbounded_channel();
+  let (tx2, rx2) = unbounded_channel();
+  let (_close_send, close_recv) = watch::channel(false);
+  let close_watcher = WorkerCloseWatcher {
+    watcher: Arc::new(Mutex::new(close_recv)),
+  };
+  let worker = Worker {
+    _close_send,
+    close_watcher: close_watcher.clone(),
+    parent_channel: parent
+      .as_ref()
+      .map(move |_| WorkerChannel {
+        tx: tx1,
+        rx: rx2.into(),
+      })
+      .into(),
+    parent_close_watcher: parent.into(),
+  };
+  let worker_host_side = WorkerHostSide {
+    close_watcher,
+    worker_channel: WorkerChannel {
+      tx: tx2,
+      rx: rx1.into(),
+    },
+  };
+  (worker, worker_host_side)
+}
+
+#[op2]
+pub fn op_worker_spawn<'s>(
+  scope: &mut v8::HandleScope<'s>,
+  #[state] this_worker: &Worker,
+  #[state] output: &Output,
+  #[string] base_url: String,
+  #[string] main_script: String,
+) -> Result<v8::Local<'s, v8::Object>, Error> {
+  let output = output.clone();
+  let close_watcher = this_worker.close_watcher.clone();
+  let (init_send, init_recv) = channel();
+  let (shutdown_tx, shutdown_rx) = unbounded_channel();
+  std::thread::spawn(move || {
+    let (mut runtime, worker_host_side) =
+      create_runtime(output, Some(close_watcher));
+    init_send
+      .send(WorkerControl {
+        worker_channel: worker_host_side.worker_channel,
+        close_watcher: worker_host_side.close_watcher,
+        handle: Some(runtime.v8_isolate().thread_safe_handle()),
+        shutdown_flag: Some(shutdown_tx),
+      })
+      .map_err(|_| unreachable!())
+      .unwrap();
+    let tokio = tokio::runtime::Builder::new_current_thread()
+      .enable_all()
+      .build()
+      .expect("Failed to build a runtime");
+    tokio
+      .block_on(run_worker_task(runtime, base_url, main_script, shutdown_rx))
+      .expect("Failed to complete test");
+  });
+
+  // This is technically a blocking call
+  let worker = init_recv.recv()?;
+  Ok(cppgc::make_cppgc_object(scope, worker))
+}
+
+async fn run_worker_task(
+  mut runtime: JsRuntime,
+  base_url: String,
+  main_script: String,
+  mut shutdown_rx: UnboundedReceiver<()>,
+) -> Result<(), Error> {
+  let url = Url::try_from(base_url.as_str())?.join(&main_script)?;
+  let module = runtime.load_main_module(&url, None).await?;
+  let f = runtime.mod_evaluate(module);
+  // We need this structure for the shutdown code to ensure that the output is
+  // consistent whether the v8 termination signal is sent, or the shutdown_rx is
+  // triggered.
+  if let Err(e) = poll_fn(|cx| {
+    if shutdown_rx.poll_recv(cx).is_ready() {
+      // This matches the v8 error. We'll hit both, depending on timing.
+      return Poll::Ready(Err(anyhow!("Uncaught Error: execution terminated")));
+    }
+    runtime.poll_event_loop(cx, PollEventLoopOptions::default())
+  })
+  .await
+  {
+    let state = runtime.op_state().clone();
+    let state = state.borrow();
+    let output: &Output = state.borrow();
+    for line in e.to_string().split('\n') {
+      output.line(format!("[ERR] {line}"));
+    }
+    return Ok(());
+  }
+  _ = f.await;
+  Ok(())
+}
+
+#[op2(fast)]
+pub fn op_worker_send(
+  #[cppgc] worker: &WorkerControl,
+  #[string] message: String,
+) -> Result<(), Error> {
+  worker.worker_channel.tx.send(message)?;
+  Ok(())
+}
+
+#[op2(async)]
+#[string]
+pub async fn op_worker_recv(#[cppgc] worker: &WorkerControl) -> Option<String> {
+  worker.worker_channel.rx.lock().await.recv().await
+}
+
+#[op2]
+pub fn op_worker_parent<'s>(
+  scope: &mut v8::HandleScope<'s>,
+  state: Rc<RefCell<OpState>>,
+) -> Result<v8::Local<'s, v8::Object>, Error> {
+  let state = state.borrow_mut();
+  let worker: &Worker = state.borrow();
+  let (Some(worker_channel), Some(close_watcher)) = (
+    worker.parent_channel.lock().unwrap().take(),
+    worker.parent_close_watcher.lock().unwrap().take(),
+  ) else {
+    bail!("No parent worker is available")
+  };
+  Ok(cppgc::make_cppgc_object(
+    scope,
+    WorkerControl {
+      worker_channel,
+      close_watcher,
+      handle: None,
+      shutdown_flag: None,
+    },
+  ))
+}
+
+#[op2(async)]
+pub async fn op_worker_await_close(#[cppgc] worker: &WorkerControl) {
+  loop {
+    if worker
+      .close_watcher
+      .watcher
+      .lock()
+      .await
+      .changed()
+      .await
+      .is_err()
+    {
+      break;
+    }
+  }
+}
+
+#[op2(fast)]
+pub fn op_worker_terminate(
+  #[cppgc] worker: &WorkerControl,
+  state: Rc<RefCell<OpState>>,
+) {
+  worker.handle.as_ref().unwrap().terminate_execution();
+  _ = worker.shutdown_flag.as_ref().unwrap().send(());
+  state.borrow_mut().waker.wake();
+}
diff --git a/testing/checkin/runner/testing.rs b/testing/checkin/runner/testing.rs
index 29640f5..258b21b 100644
--- a/testing/checkin/runner/testing.rs
+++ b/testing/checkin/runner/testing.rs
@@ -2,12 +2,23 @@
 use std::any::Any;
 use std::any::TypeId;
 use std::collections::HashMap;
+use std::sync::Arc;
+use std::sync::Mutex;
 
 use deno_core::v8;
 
-#[derive(Default)]
+#[derive(Clone, Default)]
 pub struct Output {
-  pub lines: Vec<String>,
+  pub lines: Arc<Mutex<Vec<String>>>,
+}
+impl Output {
+  pub fn line(&self, line: String) {
+    self.lines.lock().unwrap().push(line)
+  }
+
+  pub fn take(&self) -> Vec<String> {
+    std::mem::take(&mut self.lines.lock().unwrap())
+  }
 }
 
 #[derive(Default)]
diff --git a/testing/checkin/runtime/__init.js b/testing/checkin/runtime/__init.js
index 0e60401..5dbce97 100644
--- a/testing/checkin/runtime/__init.js
+++ b/testing/checkin/runtime/__init.js
@@ -4,6 +4,7 @@ import * as async from "checkin:async";
 import * as testing from "checkin:testing";
 import * as console from "checkin:console";
 import * as timers from "checkin:timers";
+import * as worker from "checkin:worker";
 testing;
 async;
 
@@ -12,6 +13,7 @@ globalThis.setTimeout = timers.setTimeout;
 globalThis.setInterval = timers.setInterval;
 globalThis.clearTimeout = timers.clearTimeout;
 globalThis.clearInterval = timers.clearInterval;
+globalThis.Worker = worker.Worker;
 Reflect.defineProperty(globalThis, "onunhandledrejection", {
   set: (cb) => {
     if (cb) {
diff --git a/testing/checkin/runtime/worker.ts b/testing/checkin/runtime/worker.ts
new file mode 100644
index 0000000..39cf537
--- /dev/null
+++ b/testing/checkin/runtime/worker.ts
@@ -0,0 +1,51 @@
+// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
+import {
+  op_worker_await_close,
+  op_worker_parent,
+  op_worker_recv,
+  op_worker_send,
+  op_worker_spawn,
+  op_worker_terminate,
+} from "ext:core/ops";
+
+const privateConstructor = Symbol();
+let parentWorker = null;
+
+export class Worker {
+  // deno-lint-ignore no-explicit-any
+  #worker: any;
+
+  // deno-lint-ignore no-explicit-any
+  constructor(privateParent: symbol, worker: any);
+  constructor(baseUrl: string, url: string);
+  constructor(arg1: unknown, arg2: unknown) {
+    if (arg1 == privateConstructor) {
+      this.#worker = arg2;
+    } else {
+      this.#worker = op_worker_spawn(arg1, arg2);
+    }
+  }
+
+  sendMessage(message: string) {
+    op_worker_send(this.#worker, message);
+  }
+
+  async receiveMessage(): Promise<string | undefined> {
+    return await op_worker_recv(this.#worker);
+  }
+
+  terminate() {
+    op_worker_terminate(this.#worker);
+  }
+
+  get closed(): Promise<void> {
+    return op_worker_await_close(this.#worker);
+  }
+
+  static get parent(): Worker {
+    if (parentWorker === null) {
+      parentWorker = new Worker(privateConstructor, op_worker_parent());
+    }
+    return parentWorker;
+  }
+}
diff --git a/testing/integration/error_async_stack/error_async_stack.out b/testing/integration/error_async_stack/error_async_stack.out
index 2ecf7c7..806bbe1 100644
--- a/testing/integration/error_async_stack/error_async_stack.out
+++ b/testing/integration/error_async_stack/error_async_stack.out
@@ -1,8 +1,8 @@
-[ERR] Error: async
-[ERR]     at test:///integration/error_async_stack/error_async_stack.js:5:13
-[ERR]     at async test:///integration/error_async_stack/error_async_stack.js:4:5
-[ERR]     at async test:///integration/error_async_stack/error_async_stack.js:9:5
 Error: async
     at test:///integration/error_async_stack/error_async_stack.js:5:13
     at async test:///integration/error_async_stack/error_async_stack.js:4:5
     at async test:///integration/error_async_stack/error_async_stack.js:9:5
+[ERR] Error: async
+[ERR]     at test:///integration/error_async_stack/error_async_stack.js:5:13
+[ERR]     at async test:///integration/error_async_stack/error_async_stack.js:4:5
+[ERR]     at async test:///integration/error_async_stack/error_async_stack.js:9:5
diff --git a/testing/integration/worker_spawn/worker.ts b/testing/integration/worker_spawn/worker.ts
new file mode 100644
index 0000000..8da2edb
--- /dev/null
+++ b/testing/integration/worker_spawn/worker.ts
@@ -0,0 +1,5 @@
+// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
+import { Worker } from "checkin:worker";
+Worker.parent.sendMessage("hello from client");
+const message = await Worker.parent.receiveMessage();
+console.log(`worker got from main "${message}"`);
diff --git a/testing/integration/worker_spawn/worker_spawn.out b/testing/integration/worker_spawn/worker_spawn.out
new file mode 100644
index 0000000..a861f46
--- /dev/null
+++ b/testing/integration/worker_spawn/worker_spawn.out
@@ -0,0 +1,5 @@
+main started
+main worker booted
+main got from worker "hello from client"
+worker got from main "ok from main"
+main exiting
diff --git a/testing/integration/worker_spawn/worker_spawn.ts b/testing/integration/worker_spawn/worker_spawn.ts
new file mode 100644
index 0000000..13c2ea3
--- /dev/null
+++ b/testing/integration/worker_spawn/worker_spawn.ts
@@ -0,0 +1,10 @@
+// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
+import { Worker } from "checkin:worker";
+console.log("main started");
+const worker = new Worker(import.meta.url, "./worker.ts");
+console.log("main worker booted");
+const message = await worker.receiveMessage();
+console.log(`main got from worker "${message}"`);
+worker.sendMessage("ok from main");
+await worker.closed;
+console.log("main exiting");
diff --git a/testing/integration/worker_terminate/worker.ts b/testing/integration/worker_terminate/worker.ts
new file mode 100644
index 0000000..8da2edb
--- /dev/null
+++ b/testing/integration/worker_terminate/worker.ts
@@ -0,0 +1,5 @@
+// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
+import { Worker } from "checkin:worker";
+Worker.parent.sendMessage("hello from client");
+const message = await Worker.parent.receiveMessage();
+console.log(`worker got from main "${message}"`);
diff --git a/testing/integration/worker_terminate/worker_terminate.out b/testing/integration/worker_terminate/worker_terminate.out
new file mode 100644
index 0000000..c48897a
--- /dev/null
+++ b/testing/integration/worker_terminate/worker_terminate.out
@@ -0,0 +1,5 @@
+main started
+main worker booted
+main got from worker "hello from client"
+[ERR] Uncaught Error: execution terminated
+main exiting
diff --git a/testing/integration/worker_terminate/worker_terminate.ts b/testing/integration/worker_terminate/worker_terminate.ts
new file mode 100644
index 0000000..5093784
--- /dev/null
+++ b/testing/integration/worker_terminate/worker_terminate.ts
@@ -0,0 +1,10 @@
+// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
+import { Worker } from "checkin:worker";
+console.log("main started");
+const worker = new Worker(import.meta.url, "./worker.ts");
+console.log("main worker booted");
+const message = await worker.receiveMessage();
+console.log(`main got from worker "${message}"`);
+worker.terminate();
+await worker.closed;
+console.log("main exiting");
diff --git a/testing/lib.rs b/testing/lib.rs
index 9a149a0..20e742e 100644
--- a/testing/lib.rs
+++ b/testing/lib.rs
@@ -59,4 +59,6 @@ integration_test!(
   timer_ref,
   timer_ref_and_cancel,
   timer_many,
+  worker_spawn,
+  worker_terminate,
 );
diff --git a/testing/tsconfig.json b/testing/tsconfig.json
index 6b80ae4..95250fd 100644
--- a/testing/tsconfig.json
+++ b/testing/tsconfig.json
@@ -5,7 +5,8 @@
     "paths": {
       "checkin:async": ["checkin/runtime/async.ts"],
       "checkin:console": ["checkin/runtime/console.ts"],
-      "checkin:testing": ["checkin/runtime/testing.ts"]
+      "checkin:testing": ["checkin/runtime/testing.ts"],
+      "checkin:worker": ["checkin/runtime/worker.ts"]
     },
     "lib": ["ESNext", "DOM", "ES2023.Array"],
     "module": "ES2022",