ReviewOS

zig-utils/zig-js

Errors: name the callee in "is not a function", matching JavaScriptCore

#889
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Parent: #459 (JavaScriptCore drop-in compatibility)

Problem

Every failed call in zig-js produces one invariant string:

TypeError: value is not a function

JavaScriptCore names the callee and describes the value it actually held:

TypeError: b1.m is not a function. (In 'b1.m()', 'b1.m' is undefined)
TypeError: a1 is not a function. (In 'a1()', 'a1' is 1)
TypeError: this.#p is not a function. (In 'this.#p()', 'this.#p' is 1)

This is not cosmetic. Error text is a real drop-in-compat surface: ported test suites and library code match on it, and X is not a function is the most frequently matched runtime TypeError in the ecosystem. A 54-case probe against the JSC oracle (home-tool run) currently matches on 4/54 cases; every one of the 50 misses is this single message.

new X() already matches JSC exactly (1 is not a constructor), so this issue is only about [[Call]].

What JSC actually emits

The message is built from raw source text, not from a re-rendered AST. The probe settles this beyond doubt — JSC preserves interior comments, newlines, and original spacing, and slices the callee up to the ( rather than to the end of the last callee token:

sourceJSC message
z1 . m ( 1 , 2 )z1 . m is not a function. (In 'z1 . m ( 1 , 2 )', 'z1 . m ' is 1)
z3/*c*/.m()z3/*c*/.m is not a function. (In 'z3/*c*/.m()', 'z3/*c*/.m' is 1)
(zr.m)(1)(zr.m) is not a function. (In '(zr.m)(1)', '(zr.m)' is 1)
f2.m?.()f2.m is not a function. (In 'f2.m?.()', 'f2.m' is 1)

So the two strings are exactly:

  • callee text — source[callee_start .. lparen_pos] (?.( bounds it at the ?.);
  • call text — source[callee_start .. rparen_end].

The callee text is a prefix of the call text, so one slice plus a prefix length carries both.

Value description

'<callee>' is <description>, where description is:

valuedescription
undefined / nullundefined / null
booleantrue / false
numberToString — 1, 1.5, 0 (for -0), NaN, Infinity, 1e+300, 1e-7
BigIntdecimal digits, no n suffix — -1
string" + the raw string + ", unescaped (a"b prints as "a"b")
symbola Symbol
objectan instance of <name>

The object <name> is JSC's calculatedClassName: a string Symbol.toStringTag if present (Map, JSON, Math, Reflect, Generator, Array Iterator), otherwise the name of the constructor found on the prototype (Object.create({constructor: function Nope(){}}) → Nope), otherwise the internal class name (Object.create(null) → Object). A Proxy reports ProxyObject regardless of its target.

Why this is now cheap

The blocker recorded previously was "ast.Node has no spans". That framing was too pessimistic: the parser already retains the source buffer for the whole parse and function nodes already carry source: []const u8 slices into it for Function.prototype.toString (Parser.sourceFrom). Retaining a call expression's own slice is the same established pattern, not new machinery.

It is also free in memory: ast.Node is 80 bytes, sized by class_expr (72). The call payload is 32; adding source: []const u8 + callee_len: u32 takes it to 56, still under the existing maximum, so ast.Node does not grow.

Required implementation

  • ast.zig / parser.zig: retain the exact call-expression source slice and the callee prefix length on ast.Node.call, bounding the callee at the ( (or the ?. of an optional call) so trailing trivia is included as JSC includes it.
  • interpreter.zig: thread the site into the [[Call]] boundary and render the JSC text. [[Call]]s that no CallExpression owns — builtin callbacks, the C API, Reflect.apply — keep a value-only form, which is what JSC does there too.
  • bytecode.zig / compiler.zig / vm.zig: the same site must reach the VM tier or the two tiers disagree on message text, which is exactly the class of divergence a green corpus hides. A sparse instruction-indexed table consulted only on the throw path keeps the hot path unchanged.
  • Both tiers must be verified to produce identical text, and per-subtree test262 --diag must show no behavioural flips.

Out of scope (tracked separately)

  • (evaluating '...') on the nullish/not-an-object family — the same retained source unlocks it, but it is a different family.
  • Tagged templates, where JSC uses a value-first form with (near '...').
9 days ago

Landed in fb7c1ae.

What shipped

ast.Node.call now retains its own source span (the whole CallExpression, plus the byte length of the callee prefix), the tree-walker threads it to the [[Call]] boundary, and the compiler binds it to the call opcode it emits so the bytecode VM reaches the same text through a sparse per-chunk table that is searched only once a callee has turned out not to be callable. Dispatch is unchanged, and ast.Node did not grow — the call payload went 32 → 56 bytes against a union still sized by class_expr at 72.

Evidence

A JSC differential (home-tool run as the oracle) over call shapes, interior trivia, argument formatting, and every value category:

beforeafter
tree-walker4/5151/51
bytecode VM (same cases inside a generator body)4/5151/51

The two tiers' output is byte-identical, which was the point — message text is exactly the kind of divergence a corpus that never asserts it will hide.

Also verified: the retained span survives a closure escaping eval plus 200k allocations on both tiers, and all six tail-position shapes match (including the tree-tail path taken when eval is shadowed by a non-callable).

--diag over the twelve subtrees this can reach: 1,196 pass / 0 fail (expressions/{call 92, optional-chaining 38, tagged-template 27, super 94, conditional 22, comma 6, logical-and 18, logical-or 18, coalesce 24}, eval-code 347, built-ins/Function/prototype 309, statements/try 201). Message text flips no corpus cases; HEAD is at 48,506/48,506 so a regression in these subtrees would surface as a failure. The full ReleaseFast sweep was not re-run.

What is deliberately still divergent

All eight remaining differential cases were re-measured and each is filed:

  • (evaluating '...') on construction and member diagnostics — #892
  • the value-only subject, and the tagged-template (near '...') form — #891
  • the optimizing tier's native dispatch, which cannot reach its chunk — #890

Correction to the record

The blocker previously recorded against this was "ast.Node has no spans, so the parenthetical is unreachable without a parser change." That was too pessimistic and cost this issue a lot of time: the parser already retains the source buffer for the whole parse and already slices it for Function.prototype.toString. Retaining a call's own span was the same established pattern, not new machinery. #892 inherits that finding.

Sign in to comment on this issue.