Include callee name in "not a function" TypeError - #1701
Open
athrvk wants to merge 1 commit into
Open
Conversation
Failed calls used to throw a TypeError with a generic message that
gives no clue about which call failed:
TypeError: not a function
Reconstruct the callee name from the bytecode of the calling function
and include it in the message, like other engines do:
let o = {}; o.foo(); // TypeError: o.foo is not a function
let x = 42; x(); // TypeError: x is not a function
The name is derived exclusively on the error path: when the
JS_CallInternal() invocation made by a call instruction returns with
the generic TypeError pending, a data flow analysis of the caller
bytecode - mirroring the graph exploration of compute_stack_size() -
determines which instruction pushed the callee (and, for member calls,
the objects it was looked up on) and the names are read from the
instruction operands, the local variable definitions and the closure
variable list. Successful calls execute exactly the same code as
before, no extra state is kept and no reference counts are touched,
and ambiguous callees (e.g. `(a ? f : g)()`) are never misattributed.
When no single name can be determined (computed properties, ambiguous
callees, stripped variable names), primitive callees are now described
by value ("undefined is not a function", "null is not a function")
and everything else keeps the generic message. This also applies to
the other JS_ThrowTypeErrorNotAFunction() call sites, e.g.
Reflect.apply() and array callbacks.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When a script calls something that is not callable, QuickJS throws a
TypeErrorwith a generic message that gives no clue about which call failed:A
ReferenceErrornames the missing variable, but the arguably more common failure (obj.foo()wherefoodoesn't exist, since the property read silently yieldsundefined) leaves the user searching by hand. Motivated by athrvk/vayu#134, where scripts embedding QuickJS surface this error to end users with no way to tell which of many call sites failed.After this change
Locals, arguments, closure variables, globals, member chains (up to 4 components),
this.*, tail calls, andeval-opcode calls with a shadowed callee are all named. This matches what V8/JSC/SpiderMonkey users expect.How it works
Everything happens on the error path. When the
JS_CallInternal()invocation made by a call opcode (OP_call*,OP_tail_call*,OP_call_method,OP_tail_call_method,OP_eval) returns an exception and the callee is not callable, the pending exception must be the generic "not a function"TypeErrorthrown before anything executed (the only other possibility — an interrupt raised by the initialjs_poll_interrupts()— is uncatchable and is left untouched). It is then rethrown with a name reconstructed from the caller's bytecode:find_stack_slot_writer()runs a forward data-flow analysis over the caller's bytecode — the same graph exploration ascompute_stack_size(), which already validated that every position has a statically known stack depth — to find the unique instruction that pushed the callee's stack slot. Writers are joined at control-flow merges, so an ambiguous callee (e.g.(a ? f : g)()) is reported as ambiguous rather than misattributed — a wrong name would be worse than none.build_callee_name()maps that instruction to a name: variable/property atoms straight from the instruction operands (get_var,get_field2, the fusedset_locforms, …), local/argument names fromvardefs, closure variable names fromclosure_var. For member calls it walks down to the object slots to reconstructa.b.c.o[expr](), ambiguous callees, stripped variable names), primitive callees are described by value instead (undefined is not a function,null is not a function), and everything else keeps the old generic message. The value fallback also applies to the otherJS_ThrowTypeErrorNotAFunction()sites (Reflect.apply, array callbacks, proxy apply, iterator methods) — only primitives are stringified there, which cannot run user code.Why this design (context: #1515)
An earlier upstream attempt (quickjs-ng/quickjs#1515) recorded the last loaded name in
JSContextat run time and was abandoned over two review concerns: atom lifetime safety and a measurable 3–5% hit on method calls from per-load bookkeeping. This implementation addresses both by construction:JSContext/JSRuntime, no bytecode format or serialization changes. Works forqjsc-compiled andJS_ReadObject()bytecode too, degrading gracefully when debug info is stripped.Validation
make test: 0/111 errors (before and after).test262-fast.conf, strict+nostrict): 52/81192 errors both before and after — failing-test sets diffed and identical, all pre-existing upstream failures.test_not_a_function()intests/test_language.js: 19 cases (38 assertions) covering every named form, the fusedOP_set_loccase, ambiguity, and the value fallbacks.withblocks,finally/gosub, generators, async, optional calls, direct/indirecteval, constructors) — clean."not a function"thrown inside a callable callee is never rewritten (guarded by the callability check); uncatchable interrupt errors are never replaced.Performance
Call-focused benchmark (20M iterations/round, best of 5 rounds × 8 interleaved process runs, Release build):
f(x)o.m(x)Parity — differences are within run-to-run noise, as expected since the fast path is unchanged.
tests/microbench.jsshows no change beyond environment noise either.Notes for reviewers
6 * byte_code_lenbytes) plus a worklist for the duration of one error throw; all allocations go through the non-throwingjs_malloc_rt()/js_realloc_rt(), so the analysis can never replace the pending exception with an out-of-memory error — any failure just falls back to the generic message.sf->cur_pcalready points past the call instruction), sostackcontents and positions are identical to before.test_constructor) still pass.