Reproduces in the default engine on ea3ff5b4 — this is not a .required-mode-only tier artifact.
var callCount = 0;
(function() {
function f(n) { "use strict"; if (n === 0) { callCount += 1; return; } return g(n - 1); }
eval("var g = f;"); // enclosing sloppy function's direct eval creates `g`
f(5);
})();
print("callCount=" + callCount);| tree-walker | VM | default | node v24 | |
|---|---|---|---|---|
callee named g | 1 | ReferenceError: Can't find variable: g | ReferenceError | 1 |
callee named eval | 1 | 0 | 0 | 1 |
The second shape is the nastier one. With the callee named eval, resolution falls back to the global %eval% intrinsic, so eval(n - 1) is a direct eval of a non-string, which returns its argument unchanged. The recursion silently never happens and callCount stays 0 — a wrong answer with no error. Test262 language/expressions/call/tco-non-eval-function-dynamic.js covers exactly this and is the only pass->fail_runtime divergence in language/expressions (the other 13 there are the expected by-design tco-* pattern).
Cause
readMayObserveOwnDirectEvalVar (src/compiler.zig:2405) considers only the current function's scope, and only an .upval resolution:
fn readMayObserveOwnDirectEvalVar(self: *Compiler, resolved: anytype) bool {
const scope = self.scope orelse return false;
if (!scope.may_extend_environment) return false;
return resolved == .upval;
}Its doc comment scopes it to "this function's own body", which is what f75b7de4 fixed (direct_eval_frame_depth 0). Here the eval belongs to an enclosing frame, and f is strict with no eval of its own, so the guard returns false twice over:
- only
self.scopeis consulted, never the parent chain — compareclassDeferredBodiesCaptureFrame(src/compiler.zig:1431), which does walkframe_scope.parentchecking the same flag; - only
.upvalqualifies, butghas no static binding anywhere, so it resolves.global.
With no binding-reference plan, the read is emitted as a plain global load, which never consults the enclosing activation's lazily materialized direct-eval environment.
The runtime already supports the general case: resolve_binding_ref (src/wasm/../vm.zig, plan.direct_eval_frame_depth) walks frame.parent that many times and adopts the target's direct_eval_environment when it owns the name. Only the compiler declines to ask for a non-zero depth.
Reachability
f is strict and self-recursive, so plainFunctionMayUseBytecode admits it and the default engine tiers it — hence the default-engine reproduction above, rather than this being visible only under forced bytecode.