5.8 JS Runtime

Overview

The JS runtime serves as the central handler for executing JavaScript code within Deno. Instead of being a standalone JavaScript engine, it acts as a bridge between Deno and the v8 engine. The JS runtime leverages a component called "rusty_v8" to establish communication with the v8 engine. This communication facilitates various operations, ranging from loading and initializing modules to executing code and managing the well-known event loop. A substantial portion of code in the JS runtime is dedicated to interfacing with v8.

In the context of any user-level program, the JS runtime offers a "future" that reaches completion under three primary conditions:

  1. Encountering an error during execution.

  2. Successful evaluation of all modules.

  3. Completion of all pending operations.

Should none of the above scenarios occur, the future remains in a continuous polling state within the event loop. To illustrate, consider our "hello world" example where the future concludes swiftly due to the absence of waiting operations.

It's important to note that the JS runtime handles a multitude of tasks, contributing to its inherent complexity. Here, we'll provide a high-level overview of the JS runtime's functionality. As we delve into the execution of scripts, we'll gain a more comprehensive understanding of its inner workings. Undoubtedly, the JS runtime serves as a pivotal hub where numerous significant actions take place, making it a critical component within Deno's ecosystem.

Initialization of JS runtime

The process of initializing the JavaScript runtime occurs through several sequential steps. Let's take a closer look at these steps to gain a better understanding.

v8_init

The initial step involves setting up v8, the core engine of Deno.

  • Begin by generating a fresh default platform within the v8 framework.

  • Proceed to initialize this platform.

  • Finally, kickstart the v8 engine itself.

pub fn v8_init() {
  let platform = v8::new_default_platform(0, false).make_shared();
  v8::V8::initialize_platform(platform);
  v8::V8::initialize();
}

Global context

In the world of v8, we encounter the concept of "contexts." These contexts pertain to scripts or modules and play an important role. Yet, alongside them, there exists what we call the "global context." This special global context comes into existence as soon as v8 is initialized for operation. It serves a vital purpose in various aspects, including module loading, instantiation, and more.

The process of establishing this global context unfolds through a series of steps, with the initial stage involving the creation of what are known as "isolates." These isolates are integral components originating from v8 itself. If you're curious to delve deeper into the specifics of isolates, you can find valuable insights on the website v8.dev. Essentially, isolates are set up and initialized at this point in our discussion. This marks the foundation of the broader context in which code and modules operate within Deno.

let refs = bindings::external_references(&op_ctxs, &additional_references);
// V8 takes ownership of external_references.
let refs: &'static v8::ExternalReferences = Box::leak(Box::new(refs));

let mut isolate = if will_snapshot {
  snapshot_util::create_snapshot_creator(
    refs,
    options.startup_snapshot.take(),
  )
} else {
  let mut params = options
    .create_params
    .take()
    .unwrap_or_default()
    .embedder_wrapper_type_info_offsets(
      V8_WRAPPER_TYPE_INDEX,
      V8_WRAPPER_OBJECT_INDEX,
    )
    .external_references(&**refs);
  if let Some(snapshot) = options.startup_snapshot.take() {
    params = match snapshot {
      Snapshot::Static(data) => params.snapshot_blob(data),
      Snapshot::JustCreated(data) => params.snapshot_blob(data),
      Snapshot::Boxed(data) => params.snapshot_blob(data),
    };
  }
  v8::Isolate::new(params)
};

for op_ctx in op_ctxs.iter_mut() {
  op_ctx.isolate = isolate.as_mut() as *mut Isolate;
}
context_state.borrow_mut().op_ctxs = op_ctxs;

isolate.set_capture_stack_trace_for_uncaught_exceptions(true, 10);
isolate.set_promise_reject_callback(bindings::promise_reject_callback);
isolate.set_host_initialize_import_meta_object_callback(
  bindings::host_initialize_import_meta_object_callback,
);
isolate.set_host_import_module_dynamically_callback(
  bindings::host_import_module_dynamically_callback,
);
isolate.set_wasm_async_resolve_promise_callback(
  bindings::wasm_async_resolve_promise_callback,
);

let (main_context, snapshotted_data) = {
  let scope = &mut v8::HandleScope::new(&mut isolate);

  let context = create_context(
    scope,
    &global_template_middlewares,
    &global_object_middlewares,
  );

  // Get module map data from the snapshot
  let snapshotted_data = if init_mode == InitMode::FromSnapshot {
    Some(snapshot_util::get_snapshotted_data(scope, context))
  } else {
    None
  };

  (v8::Global::new(scope, context), snapshotted_data)
};

State

The JsRuntime requires a complex state that it manages using v8's slots. This state is composed of several different elements, including:

isolate.set_slot(Rc::new(RefCell::new(JsRuntimeState {
      global_context: Some(global_context),
      pending_promise_exceptions: HashMap::new(),
      pending_dyn_mod_evaluate: HashMap::new(),
      pending_mod_evaluate: None,
      shared_ab: None,
      js_recv_cb: None,
      js_macrotask_cb: None,
      js_error_create_fn,
      shared: SharedQueue::new(RECOMMENDED_SIZE),
      pending_ops: FuturesUnordered::new(),
      pending_unref_ops: FuturesUnordered::new(),
      op_state: Rc::new(RefCell::new(op_state)),
      have_unpolled_ops: Cell::new(false),
      modules: Modules::new(),
      loader,
      dyn_import_map: HashMap::new(),
      preparing_dyn_imports: FuturesUnordered::new(),
      pending_dyn_imports: FuturesUnordered::new(),
      waker: AtomicWaker::new(),
    })));

Deno operates while keeping track of numerous states to ensure its proper functioning. As we delve further into running our code, we will uncover some of these states. This concludes our discussion on the initialization process. Now, let's explore how the JsRuntime carries out the execution of programs. For the time being, we will maintain a higher-level perspective to grasp the overall process.

Execute

In Deno, the JsRuntime offers a handy tool called "execute_script()" for carrying out script execution within the V8 engine. It's crucial to keep in mind that Deno serves as a runtime environment, not merely a JavaScript execution engine. The actual running of scripts takes place within the V8 engine.

The purpose of the "execute_script()" function is to handle the execution of conventional JavaScript code, which refers to code without ES modules. If your code follows the traditional structure, this function is your go-to. However, if you're dealing with code organized into ES modules, fret not! Deno provides distinct functions tailored for such module-based JavaScript.

An interesting point to note is that the "execute_script()" function operates within the global context. This means that any code executed using this function will interact with the broader environment and variables. This global context approach might impact how your code behaves and communicates with other parts of your program.

In essence, Deno's JsRuntime simplifies script execution through the "execute_script()" function, emphasizing compatibility with traditional JavaScript while accommodating module-based code through separate functions. Understanding the distinction between these execution methods can significantly enhance your effectiveness when working with Deno.

pub fn execute_script(
    &self,
    isolate: &mut v8::Isolate,
    name: &'static str,
    source_code: ModuleCode,
  ) -> Result<v8::Global<v8::Value>, Error> {
    let scope = &mut self.0.handle_scope(isolate);

    let source = Self::string_from_code(scope, &source_code).unwrap();
    debug_assert!(name.is_ascii());
    let name =
      v8::String::new_external_onebyte_static(scope, name.as_bytes()).unwrap();
    let origin = bindings::script_origin(scope, name);

    let tc_scope = &mut v8::TryCatch::new(scope);

    let script = match v8::Script::compile(tc_scope, source, Some(&origin)) {
      Some(script) => script,
      None => {
        let exception = tc_scope.exception().unwrap();
        return exception_to_err_result(tc_scope, exception, false);
      }
    };

    match script.run(tc_scope) {
      Some(value) => {
        let value_handle = v8::Global::new(tc_scope, value);
        Ok(value_handle)
      }
      None => {
        assert!(tc_scope.has_caught());
        let exception = tc_scope.exception().unwrap();
        exception_to_err_result(tc_scope, exception, false)
      }
    }
  }

The execution of a script in Deno involves several significant steps that work together to make things happen:

  • Obtaining the Global Context: At the outset, Deno fetches the global context, which provides the foundational environment for your script's execution.

  • Initializing the Script or File: Next, Deno takes the necessary actions to set up and prepare the script or file for execution, ensuring that everything is in place.

  • Setting Up v8's TryCatch Error Handler: Deno allocates v8's TryCatch error handler, a tool that helps manage and capture errors during the execution process, enhancing the reliability of the script.

  • Compiling the Script: The script is then compiled, which involves transforming the human-readable code into a form that the computer can understand and execute.

  • Running the Script: With all the groundwork laid, Deno finally runs the compiled script, bringing it to life and allowing it to perform its intended tasks.

This sequence of steps showcases the intricate process Deno follows to take your code from its initial form to actual execution. Each step plays a crucial role in ensuring that your script runs smoothly and successfully carries out its desired operations.

ES Modules

One crucial role of the JS runtime is to facilitate and manage ES modules, which have distinct requirements compared to traditional JS code. The JS runtime accomplishes this through a range of significant functions.

Within the realm of the JS runtime, numerous functions are dedicated to aiding ES modules. Let's delve into a brief overview of a selection of these pivotal functions:

Function

Use

new_es_module

  • Called during module loading or dynamic import loading

  • Creates a module

  • Compiles module in v8

  • Returns compiled module id

instantiate_module

  • Needs module id (this comes from new_es_module)

  • Instantiates an ES module with module id

mod_evaluate

  • Evaluate an instantiated ES module

  • This is equivalent to running a JS code

dyn_mod_evaluate

  • This is for dynamic imports (similar functionality as dyn_mod_evaluate)

load_main_module

  • Asynchronously load the main module and all of its dependencies

  • This is a recursive procedure

load_side_module

  • Used to load some module that is not part of the main module and it's dependencies

One crucial role of the JS runtime is to facilitate and manage ES modules, which have distinct requirements compared to traditional JS code. The JS runtime accomplishes this through a range of significant functions.

Within the realm of the JS runtime, numerous functions are dedicated to aiding ES modules. Let's delve into a brief overview of a selection of these pivotal functions:

Event loop

The JavaScript runtime also operates the renowned event loop, which has gained prominence within the realm of JavaScript, particularly through Node.js. This event loop can be considered as a kind of enchanting cycle that remains active until a program has completed its tasks.

In essence, the event loop represents a path to the future, persistently operating as long as there are tasks yet to be accomplished. Its fundamental code structure can be depicted as follows:

pub async fn run_event_loop(
    &mut self,
    wait_for_inspector: bool,
  ) -> Result<(), Error> {
    poll_fn(|cx| self.poll_event_loop(cx, wait_for_inspector)).await
}

pub fn poll_event_loop(
    &mut self,
    cx: &mut Context,
    wait_for_inspector: bool,
  ) -> Poll<Result<(), Error>> {
    let has_inspector: bool;

    {
      let state = self.inner.state.borrow();
      has_inspector = state.inspector.is_some();
      state.op_state.borrow().waker.register(cx.waker());
    }

    if has_inspector {
      // We poll the inspector first.
      let _ = self.inspector().borrow().poll_sessions(Some(cx)).unwrap();
    }

    self.pump_v8_message_loop()?;

    // Dynamic module loading - ie. modules loaded using "import()"
    {
      // Run in a loop so that dynamic imports that only depend on another
      // dynamic import can be resolved in this event loop iteration.
      //
      // For example, a dynamically imported module like the following can be
      // immediately resolved after `dependency.ts` is fully evaluated, but it
      // wouldn't if not for this loop.
      //
      //    await delay(1000);
      //    await import("./dependency.ts");
      //    console.log("test")
      //
      // These dynamic import dependencies can be cross-realm:
      //
      //    await delay(1000);
      //    await new ShadowRealm().importValue("./dependency.js", "default");
      //
      loop {
        let mut has_evaluated = false;

        let state = self.inner.state.borrow();
        if state.known_realms.len() == 1 {
          drop(state);
          // Try and resolve as many dynamic imports in each realm as possible
          // before moving to the next.
          let realm = self.inner.main_realm.as_ref().unwrap();
          loop {
            let poll_imports =
              realm.prepare_dyn_imports(&mut self.inner.v8_isolate, cx)?;
            assert!(poll_imports.is_ready());

            let poll_imports =
              realm.poll_dyn_imports(&mut self.inner.v8_isolate, cx)?;
            assert!(poll_imports.is_ready());

            if realm.evaluate_dyn_imports(&mut self.inner.v8_isolate) {
              has_evaluated = true;
            } else {
              break;
            }
          }
        } else {
          // TODO(bartlomieju|mmastrac): Remove cloning in the runtime loop
          let realms = state.known_realms.clone();
          drop(state);
          for inner_realm in realms {
            let realm = JsRealm::new(inner_realm);

            // Try and resolve as many dynamic imports in each realm as possible
            // before moving to the next.
            loop {
              let poll_imports =
                realm.prepare_dyn_imports(&mut self.inner.v8_isolate, cx)?;
              assert!(poll_imports.is_ready());

              let poll_imports =
                realm.poll_dyn_imports(&mut self.inner.v8_isolate, cx)?;
              assert!(poll_imports.is_ready());

              if realm.evaluate_dyn_imports(&mut self.inner.v8_isolate) {
                has_evaluated = true;
              } else {
                break;
              }
            }
          }
        }
        if !has_evaluated {
          break;
        }
      }
    }

    // Resolve async ops, run all next tick callbacks and macrotasks callbacks
    // and only then check for any promise exceptions (`unhandledrejection`
    // handlers are run in macrotasks callbacks so we need to let them run
    // first).
    let dispatched_ops = self.do_js_event_loop_tick(cx)?;
    self.check_promise_rejections()?;

    // Event loop middlewares
    let mut maybe_scheduling = false;
    {
      let op_state = self.inner.state.borrow().op_state.clone();
      for f in &self.event_loop_middlewares {
        if f(op_state.clone(), cx) {
          maybe_scheduling = true;
        }
      }
    }

    // Top level module
    {
      let state = self.inner.state.borrow();
      if state.known_realms.len() == 1 {
        drop(state);
        let realm = self.inner.main_realm.as_ref().unwrap();
        realm.evaluate_pending_module(&mut self.inner.v8_isolate);
      } else {
        // TODO(bartlomieju|mmastrac): Remove cloning in the runtime loop
        let realms = state.known_realms.clone();
        drop(state);
        for inner_realm in realms {
          let realm = JsRealm::new(inner_realm);
          realm.evaluate_pending_module(&mut self.inner.v8_isolate);
        }
      }
    }

    let pending_state = self.event_loop_pending_state();
    if !pending_state.is_pending() && !maybe_scheduling {
      if has_inspector {
        let inspector = self.inspector();
        let has_active_sessions = inspector.borrow().has_active_sessions();
        let has_blocking_sessions = inspector.borrow().has_blocking_sessions();

        if wait_for_inspector && has_active_sessions {
          // If there are no blocking sessions (eg. REPL) we can now notify
          // debugger that the program has finished running and we're ready
          // to exit the process once debugger disconnects.
          if !has_blocking_sessions {
            let context = self.main_context();
            let scope = &mut self.handle_scope();
            inspector.borrow_mut().context_destroyed(scope, context);
            println!("Program finished. Waiting for inspector to disconnect to exit the process...");
          }

          return Poll::Pending;
        }
      }

      return Poll::Ready(Ok(()));
    }

    let state = self.inner.state.borrow();

    // Check if more async ops have been dispatched
    // during this turn of event loop.
    // If there are any pending background tasks, we also wake the runtime to
    // make sure we don't miss them.
    // TODO(andreubotella) The event loop will spin as long as there are pending
    // background tasks. We should look into having V8 notify us when a
    // background task is done.
    if pending_state.has_pending_background_tasks
      || pending_state.has_tick_scheduled
      || maybe_scheduling
    {
      state.op_state.borrow().waker.wake();
    }

    // If ops were dispatched we may have progress on pending modules that we should re-check
    if (pending_state.has_pending_module_evaluation
      || pending_state.has_pending_dyn_module_evaluation)
      && dispatched_ops
    {
      state.op_state.borrow().waker.wake();
    }

    drop(state);

    if pending_state.has_pending_module_evaluation {
      if pending_state.has_pending_refed_ops
        || pending_state.has_pending_dyn_imports
        || pending_state.has_pending_dyn_module_evaluation
        || pending_state.has_pending_background_tasks
        || pending_state.has_tick_scheduled
        || maybe_scheduling
      {
        // pass, will be polled again
      } else {
        let known_realms = &self.inner.state.borrow().known_realms;
        return Poll::Ready(Err(
          find_and_report_stalled_level_await_in_any_realm(
            &mut self.inner.v8_isolate,
            known_realms,
          ),
        ));
      }
    }

    if pending_state.has_pending_dyn_module_evaluation {
      if pending_state.has_pending_refed_ops
        || pending_state.has_pending_dyn_imports
        || pending_state.has_pending_background_tasks
        || pending_state.has_tick_scheduled
      {
        // pass, will be polled again
      } else if self.inner.state.borrow().dyn_module_evaluate_idle_counter >= 1
      {
        let known_realms = &self.inner.state.borrow().known_realms;
        return Poll::Ready(Err(
          find_and_report_stalled_level_await_in_any_realm(
            &mut self.inner.v8_isolate,
            known_realms,
          ),
        ));
      } else {
        let mut state = self.inner.state.borrow_mut();
        // Delay the above error by one spin of the event loop. A dynamic import
        // evaluation may complete during this, in which case the counter will
        // reset.
        state.dyn_module_evaluate_idle_counter += 1;
        state.op_state.borrow().waker.wake();
      }
    }

    Poll::Pending
  }

  fn event_loop_pending_state(&mut self) -> EventLoopPendingState {
    let mut scope = v8::HandleScope::new(self.inner.v8_isolate.as_mut());
    EventLoopPendingState::new(&mut scope, &mut self.inner.state.borrow_mut())
  }
}

Understanding the event loop might seem a bit tricky without delving into the actual code. We will get a clearer picture of the event loop as we walk through the evaluation process of the main module in the hello world program. But for now, let's grasp a high-level overview of what the event loop accomplishes.

The event loop mechanism is best understood by examining the poll_event_loop function. This function operates asynchronously and continues running until certain conditions are met:

  1. An error is encountered, or

  2. All tasks are completed, implying that there's nothing left to be done. This involves checking for various scenarios:

    • No pending operations are waiting to be executed.

    • No dynamic imports are in the queue, waiting for their turn.

    • All pending dynamic imports have been successfully evaluated.

    • All modules in the evaluation queue have been processed.

    • No active inspector sessions are ongoing.

By adhering to these conditions, the event loop ensures the orderly execution of tasks within the Deno runtime environment. As we proceed, we will dive into the intricacies of these concepts, shedding light on the orchestration of asynchronous operations and module evaluations, ultimately resulting in a well-functioning Deno application.

If there's nothing to be done, the event loop comes to a halt. In our illustration, the event loop would conclude swiftly. However, in real-world scenarios, this loop would hardly ever reach its end. Take, for instance, a web server—it perpetually remains poised to receive new connections. Applications in production would generally only terminate when an error surfaces.

Let's delve into some higher-level tasks that the poll event loop undertakes:

  1. Polling Pending Operations: It scans for operations that are pending, waiting for them to become available.

  2. Verifying Responses from Asynchronous Operations: The loop checks the responses from asynchronous operations, making sure they are ready.

  3. Draining Macrotasks: It processes larger-scale tasks, often known as "macrotasks," from the queue.

  4. Handling Promise Exceptions: The loop takes a look at promise-based operations to catch and handle any exceptions that might have occurred.

  5. Prepping Dynamic Module Imports: It readies the environment for importing dynamic modules, which are modules that can be loaded on demand.

  6. Polling Dynamic Imports: The loop keeps an eye out for dynamically imported modules, ensuring they're fetched when necessary.

  7. Evaluating Dynamic Module Imports: It assesses and evaluates the dynamically imported modules.

  8. Checking Promise Exceptions Again: The loop revisits promise-based operations to double-check for exceptions.

  9. Assessing Pending Top-Level Modules: It evaluates and processes any pending top-level modules that are waiting to be executed.

  10. Verifying for Exceptions: The loop examines the code for any potential exceptions before proceeding.

As we continue through our program in this and the subsequent chapters, we will gradually uncover different facets of the event loop. Without a relevant context or an illustrative example, comprehending the intricacies of the event loop can be quite challenging.

Next steps

That concludes the discussion on the JS runtime. Up until now, we've delved into the process of initialization, which primarily focused on getting things ready. Let's transition to the exciting phase of action—executing the user-level code in our simple "hello world" program. Our attention now shifts to the upcoming segment, which is all about carrying out the main module's execution. In the following passages, we will explore the intricacies of loading and running our code, unraveling the steps that bring our program to life.

Last updated