Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
// becomes part of every emitted global so multi-module programs
// don't collide on `.str.0.handle`.
let mut strings = StringPool::with_prefix(module_prefix.clone());
// #5247: install per-module source-location context for the dynamic
// call-dispatch throw path, but only under `--debug-symbols` (which sets
// `opts.debug_locations` + `opts.module_source`). Off by default — no
// source clone, no per-call emission.
if opts.debug_locations {
if let Some(src) = opts.module_source.clone() {
strings.set_debug_location_ctx(Some((hir.name.clone(), src)));
}
}

// Class lookup table for `Expr::New`. Indexed by class name —
// the HIR has unique names per module.
Expand Down
14 changes: 14 additions & 0 deletions crates/perry-codegen/src/codegen/opts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,20 @@ pub struct CompileOptions {
/// Without this, side-effect-only dynamic-import targets fail at
/// link with `Undefined symbols: ___perry_ns_<prefix>`.
pub is_dynamic_import_target: bool,

/// #5247: emit source-location tracking for the dynamic call-dispatch
/// throw path (`X is not a function`). Gated by the CLI `--debug-symbols`
/// flag; default `false` so release builds are byte-identical and incur
/// no per-call overhead. When `true` (and `module_source` is present),
/// each dynamic method-call dispatch is preceded by a
/// `js_set_call_location(file, line)` so the thrown TypeError's `.stack`
/// shows `at <file>:<line>`.
pub debug_locations: bool,
/// #5247: this module's original source text, used at codegen to resolve a
/// `Call`'s `byte_offset` to a 1-based line number. Only set when
/// `debug_locations` is on (avoids cloning source for every module in the
/// common build). `None` falls back to the `<anonymous>` frame.
pub module_source: Option<String>,
}

/// Issue #100: one entry in a module's namespace-population list.
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/collectors/hir_facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,7 @@ mod tests {
callee: Box::new(Expr::FuncRef(7)),
args: vec![arg, Expr::Integer(0), Expr::Integer(100)],
type_args: vec![],
byte_offset: 0,
};
// let src = undefined; src = obj.value; (disqualified seed)
// const xx = clamp3(src, 0, 100); (clamp-admitted)
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/collectors/pointer_locals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,7 @@ mod tests {
callee: Box::new(Expr::FuncRef(1)),
args: vec![Expr::LocalGet(2)],
type_args: Vec::new(),
byte_offset: 0,
}))],
captures: Vec::new(),
mutable_captures: Vec::new(),
Expand Down
50 changes: 49 additions & 1 deletion crates/perry-codegen/src/expr/calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,39 @@ use super::{
I18nLowerCtx,
};

/// #5247: under `--debug-symbols`, emit a `js_set_call_location(file, line)`
/// runtime call right before a dynamic method dispatch so the
/// "X is not a function" throw path can render `at <file>:<line>` in the thrown
/// TypeError's `.stack`. Resolves the *pending* call byte offset (recorded by
/// the `Expr::Call` dispatcher) → `(file, line)` via the module's installed
/// debug-location context. No-op (no IR emitted) when the context is absent
/// (default build) or the pending offset is 0 (synthesized call).
///
/// Called at the dispatch emission site (after the call's arguments are
/// lowered) with the offset the dispatcher captured at entry — before any
/// nested-call argument overwrote the shared pending offset — so the location
/// reflects the OUTER call, not its last-lowered argument.
pub(crate) fn emit_call_location_at(ctx: &mut FnCtx<'_>, byte_offset: u32) {
let Some((file, line)) = ctx
.strings
.call_location_for(byte_offset)
.map(|(f, l)| (f.to_string(), l))
else {
return;
};
Comment on lines +63 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear stale call-location state when no location is resolvable.

On Line 67, returning early skips the runtime clear path. If a prior dispatch set a location, later offsetless/synthesized calls can inherit that stale <file>:<line> instead of falling back to <anonymous>.

Suggested fix
 pub(crate) fn emit_call_location_at(ctx: &mut FnCtx<'_>, byte_offset: u32) {
     let Some((file, line)) = ctx
         .strings
         .call_location_for(byte_offset)
         .map(|(f, l)| (f.to_string(), l))
     else {
+        // Clear any previously latched call-site location.
+        ctx.block().call_void(
+            "js_set_call_location",
+            &[(PTR, "null"), (I64, "0"), (I32, "0")],
+        );
         return;
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/expr/calls.rs` around lines 63 - 69, The early
return in the else block (when call_location_for returns None) skips the runtime
clear path, allowing stale location state from prior dispatches to persist.
Before returning when call_location_for(byte_offset) fails to resolve a
location, invoke the runtime clear operation (the same cleanup that executes in
the normal path) to reset the call location state to its default anonymous
value, ensuring offsetless or synthesized calls do not inherit stale file:line
information from previous operations.

let file_label = emit_string_literal_global(ctx, &file);
let file_len = file.len();
let blk = ctx.block();
blk.call_void(
"js_set_call_location",
&[
(PTR, &file_label),
(I64, &file_len.to_string()),
(I32, &line.to_string()),
],
);
}

/// #2013/#3146: emit a setup-time `validateString` call. `value_box` is the
/// original NaN-boxed value; `name` is the static argument name node uses in
/// the error (`"algorithm"` for `createHash`, `"hmac"` for `createHmac`'s
Expand Down Expand Up @@ -2369,14 +2402,29 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
}

// -------- Calls --------
Expr::Call { callee, args, .. } => {
Expr::Call {
callee,
args,
byte_offset,
..
} => {
for arg in args {
super::downgrade_buffer_aliases_in_expr(
ctx,
arg,
crate::native_value::MaterializationReason::UnknownCallEscape,
);
}
// #5247: under `--debug-symbols`, record this call's source byte
// offset so the dynamic method-dispatch emission site can emit a
// `js_set_call_location` immediately before the throwing dispatch
// (after the call's args — which may be nested calls that overwrite
// this — have been lowered). The dynamic dispatch path renders it as
// `at <file>:<line>` in the "X is not a function" TypeError's
// `.stack`. No-op in the default build.
if ctx.strings.debug_locations_enabled() {
ctx.strings.set_pending_call_offset(*byte_offset);
}
lower_call(ctx, callee, args)
}

Expand Down
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1402,7 +1402,7 @@ mod arrays_finds;
mod bigint_set;
mod binary;
mod call_spread;
mod calls;
pub(crate) mod calls;
mod child_proc;
mod closure;
mod compare;
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/expr/proxy_reflect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,11 +261,13 @@ fn same_put_value_receiver_expr(target: &Expr, receiver: &Expr) -> bool {
callee: a_callee,
args: a_args,
type_args: a_type_args,
..
},
Expr::Call {
callee: b_callee,
args: b_args,
type_args: b_type_args,
..
},
) => {
a_type_args == b_type_args
Expand Down
10 changes: 10 additions & 0 deletions crates/perry-codegen/src/lower_call/console_promise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,11 @@ pub fn try_lower_native_method_str_dispatch(
callee: &Expr,
args: &[Expr],
) -> Result<Option<String>> {
// #5247: capture this call's source byte offset before any argument
// (possibly a nested call that overwrites the shared pending offset) is
// lowered, so the dynamic dispatch below can emit `js_set_call_location`
// with the OUTER call's location right before the throw-capable dispatch.
let call_byte_offset = ctx.strings.pending_call_offset();
// -------- PropertyGet method dispatch via js_native_call_method --------
//
// For `recv.method(args)` where the static dispatch above didn't fire
Expand Down Expand Up @@ -788,6 +793,11 @@ pub fn try_lower_native_method_str_dispatch(
property,
TypedFeedbackContract::method_call(),
);
// #5247: record the source location right before the dynamic
// dispatch so a thrown "X is not a function" TypeError carries
// `at <file>:<line>`. Args are already lowered above, so a nested
// call's location no longer shadows this one.
crate::expr::calls::emit_call_location_at(ctx, call_byte_offset);
let blk = ctx.block();
return Ok(Some(blk.call(
DOUBLE,
Expand Down
12 changes: 12 additions & 0 deletions crates/perry-codegen/src/lower_call/property_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ pub fn try_lower_property_get_method_call(
let Expr::PropertyGet { object, property } = callee else {
return Ok(None);
};
// #5247: capture this call's source byte offset now, before any argument
// (which may be a nested call that overwrites the pending offset) is
// lowered. The dynamic `js_native_call_method` fallback below emits the
// `js_set_call_location` from this captured value, immediately before the
// throwing dispatch. `0` (and the default build) → no emission.
let call_byte_offset = ctx.strings.pending_call_offset();
if let Some(value) =
super::web_storage::try_lower_web_storage_method_call(ctx, object, property, args)?
{
Expand Down Expand Up @@ -1753,6 +1759,12 @@ pub fn try_lower_property_get_method_call(
));
(ptr_reg, n.to_string())
};
// #5247: record the source location of this call right before the
// dynamic dispatch, so the runtime "X is not a function" /
// "(kind).method is not a function" TypeError this fallback may
// throw carries `at <file>:<line>`. Args are already lowered, so a
// nested-call argument's location no longer shadows this one.
crate::expr::calls::emit_call_location_at(ctx, call_byte_offset);
let v_def = ctx.block().call(
DOUBLE,
"js_native_call_method",
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/runtime_decls/strings_part2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,9 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) {
// `TypeError [ERR_INVALID_ARG_TYPE]` instead of dereferencing a bogus
// pointer (segfault). Used by `crypto.createHash`/`createHmac`/`pbkdf2*`.
module.declare_function("js_runtime_validate_string_arg", VOID, &[DOUBLE, PTR, I32]);
// #5247: source-location tracking for the dynamic call-dispatch throw
// path. Emitted before a call's dispatch only under `--debug-symbols`.
module.declare_function("js_set_call_location", VOID, &[PTR, I64, I32]);
module.declare_function(
"js_runtime_validate_crypto_key_arg",
VOID,
Expand Down
137 changes: 137 additions & 0 deletions crates/perry-codegen/src/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ pub struct StringPool {
/// Ordered list of unique entries; the index in this Vec is the
/// interned index referenced by `interned`.
entries: Vec<StringEntry>,
/// #5247: source-location context for the dynamic call-dispatch throw
/// path. Set once per module after construction (only when the CLI
/// `--debug-symbols` flag is on). `None` in the default build so codegen
/// emits no per-call `js_set_call_location` overhead. When `Some`,
/// carries `(module_file_path, module_source)` so the call-lowering site
/// can resolve a `Call.byte_offset` to a `file:line`.
debug_location_ctx: Option<(String, String)>,
/// #5247: the byte offset of the `Expr::Call` currently being lowered,
/// recorded by the call dispatcher and consumed at the dynamic
/// method-dispatch emission site (after the call's arguments — which may
/// themselves be nested calls that overwrite this — have been lowered) so
/// the `js_set_call_location` is emitted with the *outer* call's offset
/// immediately before the throwing dispatch. `0` = none.
pending_call_offset: std::cell::Cell<u32>,
}

pub struct StringEntry {
Expand Down Expand Up @@ -107,13 +121,62 @@ impl StringPool {
module_prefix,
interned: HashMap::new(),
entries: Vec::new(),
debug_location_ctx: None,
pending_call_offset: std::cell::Cell::new(0),
}
}

pub fn module_prefix(&self) -> &str {
&self.module_prefix
}

/// #5247: install the per-module source-location context (file path +
/// source text) consulted by the dynamic call-dispatch lowering when the
/// `--debug-symbols` flag is on. No-op otherwise (`ctx` is `None`).
pub fn set_debug_location_ctx(&mut self, ctx: Option<(String, String)>) {
self.debug_location_ctx = ctx;
}

/// #5247: true iff source-location tracking is active for this module
/// (i.e. `--debug-symbols` installed a debug-location context). Lets the
/// dispatch emission site skip all location work in the default build.
pub fn debug_locations_enabled(&self) -> bool {
self.debug_location_ctx.is_some()
}

/// #5247: record the byte offset of the call currently being lowered.
pub fn set_pending_call_offset(&self, byte_offset: u32) {
self.pending_call_offset.set(byte_offset);
}

/// #5247: the byte offset recorded by the most recent
/// [`set_pending_call_offset`]. `0` when none.
pub fn pending_call_offset(&self) -> u32 {
self.pending_call_offset.get()
}

/// #5247: resolve a `Call`'s source byte offset to `(file_path, line)`,
/// where `line` is 1-based. Returns `None` when no debug-location context
/// is installed (default build), the offset is `0` (synthesized call), or
/// the offset is out of range. SWC's `BytePos` is 1-based, matching the
/// `lower` crate's `current_module_source_slice`, so subtract 1.
pub fn call_location_for(&self, byte_offset: u32) -> Option<(&str, u32)> {
if byte_offset == 0 {
return None;
}
let (file, src) = self.debug_location_ctx.as_ref()?;
let offset = (byte_offset.saturating_sub(1)) as usize;
if offset > src.len() {
return None;
}
// 1-based line = 1 + count of newlines before the offset.
let line = 1 + src.as_bytes()[..offset]
.iter()
.filter(|&&b| b == b'\n')
.count();
Some((file.as_str(), line as u32))
}

/// Intern a string literal. Returns the interned index, stable for the
/// life of the pool. Identical strings collapse to the same index.
pub fn intern(&mut self, value: &str) -> u32 {
Expand Down Expand Up @@ -276,4 +339,78 @@ mod tests {
let e = pool.entry(idx);
assert_eq!(e.byte_len, 6);
}

// ───────────────────── #5247 byte→line mapping ─────────────────────
// `call_location_for` takes an SWC `BytePos` (1-based), so byte offset N
// refers to source index N-1. Line is 1-based (1 + newlines before it).

fn pool_with_src(src: &str) -> StringPool {
let mut p = StringPool::new();
p.set_debug_location_ctx(Some(("foo.ts".to_string(), src.to_string())));
p
}

#[test]
fn call_location_none_without_debug_context() {
// Default build: no context installed → never resolves a location.
let p = StringPool::new();
assert_eq!(p.call_location_for(5), None);
}

#[test]
fn call_location_zero_offset_is_none() {
// 0 sentinel (synthesized call) → no location.
let p = pool_with_src("a\nb\nc");
assert_eq!(p.call_location_for(0), None);
}

#[test]
fn call_location_first_line() {
// Offsets within the first line (before any '\n') → line 1.
let p = pool_with_src("foo();\nbar();\n");
// BytePos 1 = source index 0 ('f'); BytePos 6 = index 5 (')').
assert_eq!(p.call_location_for(1), Some(("foo.ts", 1)));
assert_eq!(p.call_location_for(6), Some(("foo.ts", 1)));
}

#[test]
fn call_location_line_boundaries() {
// "foo();\nbar();\nbaz();\n"
// index:0..5 '\n'=6 7..12 '\n'=13 14..19 '\n'=20
let src = "foo();\nbar();\nbaz();\n";
let p = pool_with_src(src);
// BytePos for the '\n' itself (index 6 → BytePos 7) still counts as
// line 1 (no newline strictly *before* it).
assert_eq!(p.call_location_for(7), Some(("foo.ts", 1)));
// First char of line 2 ('b' at index 7 → BytePos 8): one '\n' before.
assert_eq!(p.call_location_for(8), Some(("foo.ts", 2)));
// First char of line 3 ('b' at index 14 → BytePos 15): two '\n' before.
assert_eq!(p.call_location_for(15), Some(("foo.ts", 3)));
}

#[test]
fn call_location_last_line_and_out_of_range() {
let src = "x\ny\nz"; // 5 bytes, 3 lines, no trailing newline
let p = pool_with_src(src);
// 'z' at index 4 → BytePos 5: two '\n' before → line 3.
assert_eq!(p.call_location_for(5), Some(("foo.ts", 3)));
// BytePos == len+1 (index == len): clamped, still line 3 (EOF after z).
assert_eq!(p.call_location_for(6), Some(("foo.ts", 3)));
// Far out of range → None.
assert_eq!(p.call_location_for(100), None);
}

#[test]
fn call_location_utf8_safe() {
// Multi-byte chars before the call must not panic or miscount lines —
// we count raw bytes, and the offsets are byte offsets, so slicing on
// a byte boundary the compiler produced is always valid.
// "café();\nx();" — "café();" is 8 bytes (é = 2), '\n' at index 8.
let src = "café();\nx();";
let p = pool_with_src(src);
// 'x' is at byte index 9 (after "café();\n" = 9 bytes) → BytePos 10.
assert_eq!(p.call_location_for(10), Some(("foo.ts", 2)));
// A position inside line 1 → line 1.
assert_eq!(p.call_location_for(2), Some(("foo.ts", 1)));
}
}
2 changes: 2 additions & 0 deletions crates/perry-codegen/tests/class_keys_gc_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ fn entry_opts() -> CompileOptions {
deferred_module_prefixes: std::collections::HashSet::new(),
module_init_deps: Vec::new(),
is_dynamic_import_target: false,
debug_locations: false,
module_source: None,
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/tests/constructor_recursion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ fn empty_opts() -> CompileOptions {
deferred_module_prefixes: std::collections::HashSet::new(),
module_init_deps: Vec::new(),
is_dynamic_import_target: false,
debug_locations: false,
module_source: None,
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/tests/large_object_barriers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ fn empty_opts() -> CompileOptions {
deferred_module_prefixes: std::collections::HashSet::new(),
module_init_deps: Vec::new(),
is_dynamic_import_target: false,
debug_locations: false,
module_source: None,
}
}

Expand Down
Loading
Loading