Skip to content

Commit cdbaf67

Browse files
feat: standard error diagnostics, stack traces, and formatting
1 parent 0db7457 commit cdbaf67

10 files changed

Lines changed: 156 additions & 40 deletions

File tree

crates/engine/src/convert.rs

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@ pub(crate) fn build_exception<'s>(
8787
let class = err.exception_class();
8888

8989
// Fallback/dynamic constructor lookup for classes V8 doesn't provide natively.
90-
let try_construct = |scope: &mut v8::PinScope<'s, '_>, class_name: &str, args: &[v8::Local<'s, v8::Value>]| -> Option<v8::Local<'s, v8::Value>> {
90+
let try_construct = |scope: &mut v8::PinScope<'s, '_>,
91+
class_name: &str,
92+
args: &[v8::Local<'s, v8::Value>]|
93+
-> Option<v8::Local<'s, v8::Value>> {
9194
let context = scope.get_current_context();
9295
let global = context.global(scope);
9396
let key = v8::String::new(scope, class_name)?;
@@ -102,19 +105,18 @@ pub(crate) fn build_exception<'s>(
102105
};
103106

104107
if let ExceptionClass::DomException(name) = class {
105-
if let Some(msg_val) = v8::String::new(scope, &err.exception_message()) {
106-
if let Some(name_val) = v8::String::new(scope, name) {
107-
if let Some(ex) = try_construct(scope, "DOMException", &[msg_val.into(), name_val.into()]) {
108-
return ex;
109-
}
110-
}
111-
}
112-
} else if let ExceptionClass::UriError = class {
113-
if let Some(msg_val) = v8::String::new(scope, &err.exception_message()) {
114-
if let Some(ex) = try_construct(scope, "URIError", &[msg_val.into()]) {
115-
return ex;
116-
}
108+
if let Some(msg_val) = v8::String::new(scope, &err.exception_message())
109+
&& let Some(name_val) = v8::String::new(scope, name)
110+
&& let Some(ex) =
111+
try_construct(scope, "DOMException", &[msg_val.into(), name_val.into()])
112+
{
113+
return ex;
117114
}
115+
} else if let ExceptionClass::UriError = class
116+
&& let Some(msg_val) = v8::String::new(scope, &err.exception_message())
117+
&& let Some(ex) = try_construct(scope, "URIError", &[msg_val.into()])
118+
{
119+
return ex;
118120
}
119121

120122
let text = match class.dom_exception_name() {
@@ -145,10 +147,56 @@ pub(crate) fn describe_exception(
145147
scope: &mut v8::PinnedRef<'_, v8::TryCatch<v8::HandleScope>>,
146148
fallback: &str,
147149
) -> String {
148-
match scope.exception() {
149-
Some(exception) => js_to_string(scope, exception),
150-
None => fallback.to_string(),
150+
let exception = match scope.exception() {
151+
Some(e) => e,
152+
None => return fallback.to_string(),
153+
};
154+
155+
if let Ok(obj) = v8::Local::<v8::Object>::try_from(exception) {
156+
let key = v8::String::new(scope, "stack").unwrap();
157+
if let Some(stack_val) = obj.get(scope, key.into())
158+
&& stack_val.is_string()
159+
{
160+
return stack_val.to_rust_string_lossy(scope);
161+
}
162+
}
163+
164+
if let Some(msg) = scope.message() {
165+
let text = msg.get(scope).to_rust_string_lossy(scope);
166+
// Fallback to building a minimal trace from v8::Message if .stack is missing
167+
if let Some(trace) = msg.get_stack_trace(scope)
168+
&& trace.get_frame_count() > 0
169+
&& let Some(frame) = trace.get_frame(scope, 0)
170+
{
171+
let file = frame
172+
.get_script_name_or_source_url(scope)
173+
.map(|s| s.to_rust_string_lossy(scope))
174+
.unwrap_or_else(|| "<unknown>".to_string());
175+
let line = frame.get_line_number();
176+
let col = frame.get_column();
177+
return format!("{text}\n at {file}:{line}:{col}");
178+
}
179+
return text;
180+
}
181+
182+
js_to_string(scope, exception)
183+
}
184+
185+
/// Formats an exception value (e.g. from an unhandled promise rejection) by
186+
/// extracting its `.stack` property if available, otherwise stringifying it.
187+
pub(crate) fn format_exception(
188+
scope: &mut v8::PinScope<'_, '_>,
189+
exception: v8::Local<v8::Value>,
190+
) -> String {
191+
if let Ok(obj) = v8::Local::<v8::Object>::try_from(exception) {
192+
let key = v8::String::new(scope, "stack").unwrap();
193+
if let Some(stack_val) = obj.get(scope, key.into())
194+
&& stack_val.is_string()
195+
{
196+
return stack_val.to_rust_string_lossy(scope);
197+
}
151198
}
199+
js_to_string(scope, exception)
152200
}
153201

154202
/// Coerces any V8 value to a Rust `String` via JS `String(value)` semantics.

crates/engine/src/module.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ pub(crate) fn eval_state(
293293
v8::PromiseState::Fulfilled => ModuleEvalState::Completed,
294294
v8::PromiseState::Rejected => {
295295
let reason = promise.result(scope);
296-
ModuleEvalState::Failed(js_to_string(scope, reason))
296+
ModuleEvalState::Failed(crate::convert::format_exception(scope, reason))
297297
}
298298
}
299299
}

crates/engine/src/op.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use es_runtime_common::{
2424
Capability, CapabilitySet, Error as CommonError, ExceptionClass, IntoException,
2525
};
2626

27-
use crate::convert::{build_exception, js_to_string, marshal, throw, value_to_js};
27+
use crate::convert::{build_exception, marshal, throw, value_to_js};
2828
use crate::error::{Error, Result};
2929
use crate::value::Value;
3030

@@ -696,7 +696,7 @@ pub(crate) fn take_unhandled_rejections(
696696
.iter()
697697
.map(|value| {
698698
let value = v8::Local::new(scope, value);
699-
js_to_string(scope, value)
699+
crate::convert::format_exception(scope, value)
700700
})
701701
.collect()
702702
}

crates/runtime-cli/src/main.rs

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,34 @@ fn parse_args() -> Result<Config, String> {
186186
}
187187
Err(format!("missing script argument\n\n{USAGE}"))
188188
}
189+
use std::io::IsTerminal;
190+
191+
fn print_error(err: &str) {
192+
let use_color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
193+
if !use_color {
194+
eprintln!("error: {}", err);
195+
return;
196+
}
197+
198+
let mut lines = err.lines();
199+
if let Some(first) = lines.next() {
200+
eprintln!("\x1b[1;31merror\x1b[0m: {}", first);
201+
}
202+
for line in lines {
203+
if line.starts_with(" at ") {
204+
eprintln!("\x1b[2m{}\x1b[0m", line);
205+
} else {
206+
eprintln!("{}", line);
207+
}
208+
}
209+
}
189210

190211
#[tokio::main(flavor = "current_thread")]
191212
async fn main() -> ExitCode {
192213
match run().await {
193214
Ok(()) => ExitCode::SUCCESS,
194215
Err(err) => {
195-
eprintln!("esrun: {err}");
216+
print_error(&err);
196217
ExitCode::FAILURE
197218
}
198219
}
@@ -347,18 +368,18 @@ async fn run() -> Result<(), String> {
347368
// evaluation. Report it as the primary error — its rejection also shows up
348369
// in `rejections`, so it is the one uncaught-rejection we don't re-report.
349370
if let ModuleEvalState::Failed(message) = runtime.module_eval_state() {
350-
eprintln!("Uncaught: {message}");
351-
return Err(format!("{label}: module evaluation failed"));
371+
return Err(format!("uncaught exception in {label}\n{message}"));
352372
}
353373

354374
if !rejections.is_empty() {
355-
for message in &rejections {
356-
eprintln!("Uncaught (in promise): {message}");
375+
let mut msg = format!("{} unhandled promise rejection(s)\n", rejections.len());
376+
for (i, message) in rejections.iter().enumerate() {
377+
if i > 0 {
378+
msg.push('\n');
379+
}
380+
msg.push_str(message);
357381
}
358-
return Err(format!(
359-
"{} unhandled promise rejection(s)",
360-
rejections.len()
361-
));
382+
return Err(msg);
362383
}
363384
Ok(())
364385
}

crates/runtime-cli/tests/modules.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,9 @@ fn top_level_throw_fails_with_uncaught_report() {
8787
assert!(stdout(&out).contains("before throw"), "{}", stdout(&out));
8888
// ...and the throw is reported once as Uncaught.
8989
let stderr = stderr(&out);
90-
assert!(stderr.contains("Uncaught"), "{stderr}");
90+
assert!(stderr.contains("error: uncaught exception"), "{stderr}");
9191
assert!(stderr.contains("fixture boom"), "{stderr}");
92+
assert!(stderr.contains("at file://"), "{stderr}");
9293
}
9394

9495
#[test]
@@ -419,3 +420,20 @@ fn version_flag_succeeds() {
419420
assert!(out.status.success());
420421
assert!(stdout(&out).contains("esrun"), "{}", stdout(&out));
421422
}
423+
424+
#[test]
425+
fn unhandled_rejection_reports_stack_trace() {
426+
let out = esrun()
427+
.arg("-e")
428+
.arg("setTimeout(() => { Promise.reject(new TypeError('async boom')); }, 0);")
429+
.output()
430+
.expect("spawn esrun");
431+
assert!(!out.status.success(), "should exit non-zero");
432+
let stderr = stderr(&out);
433+
assert!(
434+
stderr.contains("error: 1 unhandled promise rejection(s)"),
435+
"{stderr}"
436+
);
437+
assert!(stderr.contains("TypeError: async boom"), "{stderr}");
438+
assert!(stderr.contains("at file://"), "{stderr}");
439+
}

docs/API.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,3 +331,7 @@ await server.stop();
331331
332332
<!-- Reference links -->
333333
[D27]: ./DECISIONS.md
334+
335+
## Error Diagnostics
336+
337+
When exceptions are thrown by ES-Runtime during module evaluation or unhandled promise rejections, the original `Error` subclasses and their stack traces are preserved. The CLI automatically extracts these diagnostics and prints them elegantly with ANSI colors. The stack trace will highlight exact lines and columns of errors: `TypeError: message \n at fn (file:line:col)`.

docs/DECISIONS.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,7 @@ Status: **Locked** · **Proposed** · **Open** (needs maintainer sign-off) · **
3535
> deferred to Phase 2, when the op system gives a second consumer to design it
3636
> against; extracting it then must not change the public types. *Reason:*
3737
> avoid speculative abstraction before there is a second implementor/consumer.
38-
> - **Uncaught-exception JS class not preserved.** `engine::Error::Execution`
39-
> carries only the stringified exception message, so it maps to a generic JS
40-
> `Error` rather than the original subclass (`TypeError`, etc.). *Reason:*
41-
> reconstructing the class requires reading the thrown object's constructor and
42-
> re-mapping; deferred to Phase 2 when ops re-enter JS. *Impact:* lossy error
43-
> class on the JS round-trip.
38+
> - ~~**Uncaught-exception JS class not preserved.**~~ *Resolved (Phase 8):* Exception classes (including `DOMException` names) and JS stack traces (`at fn (file:line:col)`) are now preserved and surfaced through `engine::Error` into the CLI.
4439
> - **Primitive-only value marshaling.** `engine::Value` marshals JS primitives
4540
> plus, since Phase 6, `Value::Bytes` (`Uint8Array`/typed-array views, **copied**
4641
> to/from `Vec<u8>`). Every other value still collapses to

docs/SPEC.md

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,6 @@ Productionizing the standalone runtime *and* stabilizing the embeddable API. ESM
147147
- **`URLPattern`** → later (not covered by the `url` crate). Minor WHATWG URL conformance gaps tracked vs WPT (D18).
148148
- **ES module loading** — ☑ **implemented**: static `import`/`export`, **dynamic `import()`** (resolving with the module namespace after the imported module fully evaluates; shares instances with static imports via the realm module map), `import.meta.url`, native top-level await, **local `file:` modules** and **`node_modules` resolution for ES module packages** via the capability-checked `ModuleLoader` provider (DECISIONS D21, D22, D23). `exports` resolution covers string targets, the `import`/`default` conditions, and **subpath patterns** (`"./*"`). **Deferred:** import attributes / JSON modules, remote (`http:`) modules, and the remaining `node_modules` edges (full condition precedence beyond `import`/`default`, `imports`/`#internal`, self-reference). **Rejected by design:** CommonJS packages and `node:` builtins (§125).
149149
- **`reportError` ErrorEvent dispatch** and **sub-millisecond `performance.now`** are minimal in Phase 4; full behavior lands with the event loop / clock refinements.
150-
- **Error model & diagnostics standardization** → planned, sequenced *after* the in-flight module/perf work. Today an uncaught error surfaces as a stringified message with no stack and a redundant CLI restatement (e.g. `Uncaught: TypeError: …` then `esrun: <file>: module evaluation failed`), provider errors are doubly nested (`module loading failed: provider error: …`), and there is no color. Scope:
151-
- **Stack traces + source position** — capture and surface the JS stack (`at fn (file:line:col)`) for uncaught exceptions, module top-level throws, and unhandled rejections, via V8's `Message`/`StackTrace` API. Resolves part of the D3a leak ("exception class/stack not preserved"; DECISIONS D3a/D12).
152-
- **Stable error codes** — a taxonomy on the layer error enums (module not-found / resolution / unsupported-CommonJS / capability-denied / eval-throw …) for tooling and tests, surfaced in messages.
153-
- **CLI output format** — one coherent error block instead of the current two lines; de-nest provider errors to a single clear line + code; unify the `Uncaught:` / `Uncaught (in promise):` / `esrun: <label>:` prefixes.
154-
- **Optional color** — red header, dimmed paths/stack, gated on TTY detection and the `NO_COLOR` convention; never colored when piped/redirected.
155150
- Spans `engine` (stack/position + error-class preservation), `runtime` (typed codes), and `default-providers`/`runtime-cli` (formatting + color).
156151

157152
---

site/app/docs/errors/page.jsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import DocsShell from "../../../components/DocsShell.jsx";
2+
import CodeBlock from "../../../components/CodeBlock.jsx";
3+
4+
export default function ErrorDiagnostics() {
5+
return (
6+
<DocsShell active="/docs/errors">
7+
<p className="text-sm font-medium text-brand-600">Runtime</p>
8+
<h1 className="mt-2 text-4xl font-bold tracking-tight text-zinc-900">
9+
Error Diagnostics
10+
</h1>
11+
<p className="mt-4 text-lg leading-relaxed text-zinc-600">
12+
ES-Runtime guarantees native exception class preservation and accurate stack
13+
trace reporting. Instead of swallowing errors as generic string blocks, the
14+
runtime retains exact subclasses (like <code className="rounded bg-zinc-100 px-1 py-0.5 text-[13px]">TypeError</code>, <code className="rounded bg-zinc-100 px-1 py-0.5 text-[13px]">DOMException</code>) and precise source mapping points.
15+
</p>
16+
17+
<h2 className="mt-12 text-xl font-semibold text-zinc-900">Stack Traces</h2>
18+
<p className="mt-3 leading-relaxed text-zinc-600">
19+
When an unhandled exception or unhandled promise rejection bubbles to the
20+
top level, <code className="rounded bg-zinc-100 px-1 py-0.5 text-[13px]">esrun</code>
21+
elegantly formats the error trace in your CLI:
22+
</p>
23+
24+
<div className="mt-4">
25+
<CodeBlock code={`error: uncaught exception in my-script.mjs\nTypeError: network connection refused\n at fetchData (file:///path/to/script.mjs:10:5)\n at file:///path/to/script.mjs:2:1`} title="Terminal" lang="text" />
26+
</div>
27+
28+
<div className="mt-6 rounded-xl border border-brand-200 bg-brand-50 p-5 leading-relaxed text-brand-900">
29+
<strong>Good to know:</strong> The CLI handles color formatting seamlessly and adjusts gracefully
30+
if piped into other tools or if <code className="rounded bg-white/70 px-1.5 py-0.5 text-[13px]">NO_COLOR</code> is set.
31+
</div>
32+
</DocsShell>
33+
);
34+
}

site/components/DocsShell.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const NAV = [
3535
items: [
3636
{ href: "/docs/modules", label: "Module system" },
3737
{ href: "/docs/security", label: "Security model" },
38+
{ href: "/docs/errors", label: "Error diagnostics" },
3839
],
3940
},
4041
];

0 commit comments

Comments
 (0)