Skip to content
This repository was archived by the owner on Jul 7, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/runtime/vendored
/runtime/js/vendored
target
/runtime/tests/js/typescript_fixtures/typescript_stack_trace.ts

# Let's keep LICENSE.md in the same formatting as we use in other PL repositories
LICENSE.md
51 changes: 36 additions & 15 deletions runtime/module_loader.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::rc::Rc;

use deno_ast::{MediaType, ParseParams};
use deno_core::anyhow::anyhow;
Expand All @@ -21,7 +23,9 @@ use deno_core::anyhow::Result;
pub struct ZinniaModuleLoader {
module_root: Option<PathBuf>,
// Cache mapping file_name to source_code
code_cache: Arc<RwLock<HashMap<String, String>>>,
code_cache: Rc<RefCell<HashMap<String, String>>>,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the module loader exclusively single threaded?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great question! I am not sure, TBH.

We are running the Deno runtime on the current thread, see here:

#[tokio::main(flavor = "current_thread")]

#[tokio::main(flavor = "current_thread")]

I think that means all async tasks will be executed on the current thread too, including the asynchronous work of the module loader.

Deno uses Rc<RefCell<...>> in their example:

https://github.com/denoland/deno_core/blob/0.343.0/core/examples/ts_module_loader.rs

My knowledge of async Rust is very limited, I don't understand this deeply enough. What would you suggest to use?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see, well Rc<RefCell<...>> is used in a single-threaded environment, since Deno is using that as an example, I would assume it is single threaded. If we were to use multithreaded environments, I'd rather go for a Arc<Mutex<...>> type.

// Cache mapping module_specifier string to source_map bytes
source_maps: Rc<RefCell<HashMap<String, Vec<u8>>>>,
}

impl ZinniaModuleLoader {
Expand All @@ -34,7 +38,8 @@ impl ZinniaModuleLoader {

Ok(Self {
module_root,
code_cache: Arc::new(RwLock::new(HashMap::new())),
code_cache: Rc::new(RefCell::new(HashMap::new())),
source_maps: Rc::new(RefCell::new(HashMap::new())),
})
}
}
Expand Down Expand Up @@ -79,6 +84,7 @@ impl ModuleLoader for ZinniaModuleLoader {
let module_root = self.module_root.clone();
let maybe_referrer = maybe_referrer.cloned();
let code_cache = self.code_cache.clone();
let source_maps = self.source_maps.clone();
let module_load = async move {
let spec_str = module_specifier.as_str();

Expand Down Expand Up @@ -177,6 +183,10 @@ impl ModuleLoader for ZinniaModuleLoader {

let code = read_file_to_string(&module_path).await?;

code_cache
.borrow_mut()
.insert(spec_str.to_string(), code.clone());

let code = if should_transpile {
let parsed = deno_ast::parse_module(ParseParams {
specifier: module_specifier.clone(),
Expand All @@ -187,30 +197,34 @@ impl ModuleLoader for ZinniaModuleLoader {
maybe_syntax: None,
})
.map_err(JsErrorBox::from_err)?;
parsed
let res = parsed
.transpile(
&deno_ast::TranspileOptions {
imports_not_used_as_values: deno_ast::ImportsNotUsedAsValues::Error,
verbatim_module_syntax: true,
..Default::default()
},
&Default::default(),
&Default::default(),
&deno_ast::EmitOptions {
source_map: deno_ast::SourceMapOption::Separate,
inline_sources: true,
..Default::default()
},
)
.map_err(JsErrorBox::from_err)?
.into_source()
.text
.into_source();

if let Some(source_map) = res.source_map {
source_maps
.borrow_mut()
.insert(module_specifier.to_string(), source_map.into_bytes());
}

res.text
} else {
code
};

code_cache
.write()
.map_err(|_| {
JsErrorBox::generic("Unexpected internal error: code_cache lock was poisoned")
})?
.insert(spec_str.to_string(), code.clone());

let module = ModuleSource::new(
module_type,
ModuleSourceCode::String(code.into()),
Expand All @@ -224,9 +238,16 @@ impl ModuleLoader for ZinniaModuleLoader {
ModuleLoadResponse::Async(module_load.boxed_local())
}

fn get_source_map(&self, specifier: &str) -> Option<Cow<[u8]>> {
self.source_maps
.borrow()
.get(specifier)
.map(|v| v.clone().into())
}

fn get_source_mapped_source_line(&self, file_name: &str, line_number: usize) -> Option<String> {
log::debug!("get_source_mapped_source_line {file_name}:{line_number}");
let code_cache = self.code_cache.read().ok()?;
let code_cache = self.code_cache.borrow();
let code = code_cache.get(file_name)?;

// Based on Deno cli/module_loader.rs
Expand Down
14 changes: 14 additions & 0 deletions runtime/tests/js/typescript_fixtures/typescript_stack_trace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// This TypeScript-only block of code is removed during transpilation. A naive solution that removes
// the TypeScript code instead of replacing it with whitespace and does not apply source maps to error
// stack traces will lead to incorrect line numbers in error stack traces.
interface User {
name: string;
email: string;
}

// The part `: Error` changes the source column number
// between the TypeScript original and the transpiled code.
//
// Throw the error so that the test can verify source code line & column numbers
// in the stack trace frames but also the source code of the line throwing the exception.
const error: Error = new Error(); throw error;
31 changes: 31 additions & 0 deletions runtime/tests/runtime_integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::rc::Rc;

use anyhow::{anyhow, Context};
use deno_core::ModuleSpecifier;
use zinnia_runtime::fmt_errors::format_js_error;
use zinnia_runtime::{any_and_jserrorbox_downcast_ref, CoreError, RecordingReporter};
use zinnia_runtime::{anyhow, deno_core, run_js_module, AnyError, BootstrapOptions};

Expand Down Expand Up @@ -94,6 +95,36 @@ js_tests!(ipfs_retrieval_tests);
test_runner_tests!(passing_tests);
test_runner_tests!(failing_tests expect_failure);

#[tokio::test]
async fn typescript_stack_trace_test() -> Result<(), AnyError> {
let (_, run_error) = run_js_test_file("typescript_fixtures/typescript_stack_trace.ts").await?;
let error = run_error.ok_or_else(|| {
anyhow!("The script was expected to throw an error. Success was reported instead.")
})?;

if let Some(CoreError::Js(e)) = any_and_jserrorbox_downcast_ref::<CoreError>(&error) {
let actual_error = format_js_error(e);
// Strip ANSI codes (colors, styles)
let actual_error = console_static_text::ansi::strip_ansi_codes(&actual_error);
// Replace current working directory in stack trace file paths with a fixed placeholder
let cwd_url = ModuleSpecifier::from_file_path(std::env::current_dir().unwrap()).unwrap();
let actual_error = actual_error.replace(cwd_url.as_str(), "file:///project-root");
// Normalize line endings to Unix style (LF only)
let actual_error = actual_error.replace("\r\n", "\n");

let expected_error = r#"
Uncaught (in promise) Error
const error: Error = new Error(); throw error;
^
at file:///project-root/tests/js/typescript_fixtures/typescript_stack_trace.ts:14:22
"#;
assert_eq!(actual_error.trim(), expected_error.trim());
} else {
panic!("The script threw unexpected error: {}", error);
}
Ok(())
}

// Run all tests in a single JS file
async fn run_js_test_file(name: &str) -> Result<(Vec<String>, Option<AnyError>), AnyError> {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In retrospect, it was a mistake to use Option<AnyError> here. I would like to rework this to return Result<(), AnyError> instead, but such refactoring is out of scope of this pull request.

@NikolasHaimerl, do you have a suggestion on how to best represent a type with the following variants in Rust?

  • We cannot execute the JS/TS file -> Err(AnyError)
  • We executed the JS/TS file and collected some logs in Vec<String>. In addition to these logs, we want to provide:
    • The script executed cleanly -> ()
    • The script threw a runtime error -> AnyError

Variants:

  1. Err(AnyError)
  2. Ok((Ok(()), Vec<String>))
  3. Ok((Err(AnyError), Vec<String>))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about we wrap the logs inside the Anyhow context string?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you mean. We need to extract the logs from the results, so that we can assert what exactly was logged by the test runner on failure. Do you know how to extract one piece of a context from the Anyhow error?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am leaning towards using Result<(Result<(), AnyError>, Vec<String>), AnyError>.
I.e. Result<ScriptResult, AnyError> where ScriptResult is a tuple (Result<(), AnyError>, Vec<String>).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it help to create a named struct ScriptResult?

Alternatively, maybe we can treat the case "we cannot execute the script" the same way as "the script threw an error".

That will give us (Result<(), AnyError>, Vec<String>)

  • We cannot execute the JS/TS file -> (Err(AnyError), vec![])
  • The script executed cleanly -> (Ok(()), logs)
  • The script threw an error -> (Err(AnyError), logs)

WDYT?

I'd also like to make this refactoring as a follow-up, after I land the remaining two pull requests.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you mean. Ideally I would like to avoid having a result inside a result. We could use enums as error types to give us specific information about which error was caused by the test runner. Why do we not unwrap an error once it occurs? What is the benefit of propagating it upwards if it is only used in testing?
Usually, in Rust testing, I would expect Errors to be unwrapped as they occur within the testing suite. Error propagation, I would usually only do in production.

Copy link
Member Author

@bajtos bajtos Apr 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense.

Why do we not unwrap an error once it occurs?

In some tests, we want to assert that execution of the JS code threw an error and that the JS code produced the expected logs.

What is the benefit of propagating it upwards if it is only used in testing?
Usually, in Rust testing, I would expect Errors to be unwrapped as they occur within the testing suite. Error propagation, I would usually only do in production.

I find it easier when all test helpers use error propagation instead of unwrapping, because that way I don't have to decide in each test helper which error-handling strategy is appropriate.

Let's continue this discussion in #752

let _ = env_logger::builder().is_test(true).try_init();
Expand Down