Skip to content
Open
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
141 changes: 141 additions & 0 deletions JSTests/stress/resolve-and-get-from-scope.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// resolve_scope + get_from_scope fused into resolve_and_get_from_scope: the cases that differ in how they resolve.

function shouldBe(actual, expected) {
if (actual !== expected)
throw new Error(`bad value: ${String(actual)}, expected ${String(expected)}`);
}
function shouldThrow(func, errorType, message) {
let error;
try {
func();
} catch (e) {
error = e;
}
if (!(error instanceof errorType))
throw new Error(`expected ${errorType.name}, got ${String(error)}`);
if (message !== undefined && error.message !== message)
throw new Error(`bad message: ${error.message}`);
}

// GlobalVar, GlobalLexicalVar, GlobalProperty.
var globalVar = 1;
let globalLet = 2;
globalThis.globalProp = 3;
function readGlobals() { return globalVar + globalLet + globalProp; }
for (let i = 0; i < 1e4; ++i)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit: this new stress test hardcodes 1e4 in 11 loop bounds (lines 25, 36, 43, 64, 72, 81, 91, 104, 117, 127, 135); JSTests/README.md rule 2 requires new tests to use testLoopCount so the harness can scale iterations per configuration. Replace 1e4 with testLoopCount.

Extended reasoning...

What the issue is

JSTests/stress/resolve-and-get-from-scope.js is a newly-added test file, and every one of its tier-up loops uses a hardcoded 1e4 bound:

for (let i = 0; i < 1e4; ++i)
    shouldBe(readGlobals(), 6);

This appears at 11 sites: lines 25, 36, 43, 64, 72, 81, 91, 104, 117, 127, and 135.

Why this violates a repository requirement

JSTests/README.md:20 (pulled into the directory-scoped instructions via JSTests/CLAUDE.md) states, under "New tests are required to adhere to the following rules":

  1. Use testLoopCount or wasmTestLoopCount to control how many iterations a test runs. The jsc CLI sets these based on the configuration of the test, so tests iterate enough to tier up where that matters and exit early where it doesn't.

This is not a stylistic suggestion — it is listed as a required rule for newly-added test files. The convention is widely followed: ~2200+ files under JSTests/ reference testLoopCount.

Why nothing else prevents it

The test happens to work with 1e4 because that is roughly the default tier-up threshold, but the point of testLoopCount is that the jsc shell sets it per configuration: no-JIT / cloop configurations set it low so the test exits quickly instead of wasting 10 000 iterations that will never tier up, while eager-tier configurations may set it higher/lower as needed. A hardcoded 1e4 defeats that scaling in every configuration this file is run under.

Step-by-step

  1. run-jsc-stress-tests JSTests/stress runs resolve-and-get-from-scope.js under many configurations (e.g. .no-llint, .no-cjit, .ftl-eager-no-cjit, cloop, etc.).
  2. In each configuration, the shell sets the global testLoopCount to the appropriate iteration count for that configuration.
  3. This test ignores that global and always runs each loop 1e4 times.
  4. In configurations where JIT is disabled, all 11 loops × 10 000 iterations run in the interpreter for no benefit; in configurations with lowered thresholds, 1e4 may be far more than needed to reach FTL. The test still passes — this is purely a harness-integration/convention violation, not a correctness bug.

Impact

No runtime correctness impact — the test produces the same pass/fail result either way. The impact is on test-suite hygiene: it violates a documented repository requirement for new tests and won't scale iteration count with the harness.

Fix

Replace each 1e4 with testLoopCount:

for (let i = 0; i < testLoopCount; ++i)
    shouldBe(readGlobals(), 6);

(applied to all 11 loop bounds listed above).

shouldBe(readGlobals(), 6);
globalVar = 10;
globalLet = 20;
globalThis.globalProp = 30;
shouldBe(readGlobals(), 60);

// A global that does not exist yet: UnresolvedProperty, then GlobalProperty once defined.
function readLate() { return lateGlobal; }
shouldThrow(readLate, ReferenceError);
globalThis.lateGlobal = 7;
for (let i = 0; i < 1e4; ++i)
shouldBe(readLate(), 7);
delete globalThis.lateGlobal;
shouldThrow(readLate, ReferenceError);

// typeof on an undeclared name must not throw.
function typeofUndeclared() { return typeof neverDeclaredAnywhere; }
for (let i = 0; i < 1e4; ++i)
shouldBe(typeofUndeclared(), "undefined");

// A global lexical in its TDZ.
function readTDZ() { return tdzLet; }
shouldThrow(readTDZ, ReferenceError);
let tdzLet = 5;
shouldBe(readTDZ(), 5);

// ClosureVar through several scope levels.
function outer() {
let a = 1;
return function middle() {
let b = 2;
return function inner() {
return a + b;
};
}();
}
{
const inner = outer();
for (let i = 0; i < 1e4; ++i)
shouldBe(inner(), 3);
}

// A bare call resolved through a scope passes undefined as `this`: sloppy callee sees globalThis, strict sees undefined.
function sloppyThis() { return this; }
function strictThis() { "use strict"; return this; }
function callThem() { return [sloppyThis(), strictThis()]; }
for (let i = 0; i < 1e4; ++i) {
const [sloppy, strict] = callThem();
shouldBe(sloppy, globalThis);
shouldBe(strict, undefined);
}
{
let closureSloppy = function () { return this; };
let closureStrict = function () { "use strict"; return this; };
function callClosures() { return [closureSloppy(), closureStrict()]; }
for (let i = 0; i < 1e4; ++i) {
const [sloppy, strict] = callClosures();
shouldBe(sloppy, globalThis);
shouldBe(strict, undefined);
}
}

// Tagged templates resolved through a scope likewise.
function tag(strings) { return [this, strings[0]]; }
function callTag() { return tag`x`; }
for (let i = 0; i < 1e4; ++i) {
const [thisValue, str] = callTag();
shouldBe(thisValue, globalThis);
shouldBe(str, "x");
}

// Inside `with`, resolution is dynamic and stays unfused: the with object wins and is `this` for calls.
function withRead(obj) {
with (obj)
return [globalVar, f()];
}
{
const obj = { globalVar: 99, f() { return this; } };
for (let i = 0; i < 1e4; ++i) {
const [value, thisValue] = withRead(obj);
shouldBe(value, 99);
shouldBe(thisValue, obj);
}
const [value, thisValue] = withRead({ f() { return this; } });
shouldBe(value, 10);
}

// Sloppy direct eval injecting a var flips the *WithVarInjectionChecks types.
function injected() {
eval("var injectedVar = 1");
function read() { return injectedVar; }
for (let i = 0; i < 1e4; ++i)
shouldBe(read(), 1);
eval("var injectedVar = 2");
shouldBe(read(), 2);
}
injected();

// A read of a global whose lexical binding epoch changes after caching.
function readShadowed() { return shadowedLater; }
globalThis.shadowedLater = "prop";
for (let i = 0; i < 1e4; ++i)
shouldBe(readShadowed(), "prop");

// A function nested inside `with` resolves through the with object at runtime even though its own scope chain is static.
{
const obj = { h() { return this; }, nestedX: 1 };
function makeNested() { with (obj) { return function nested() { return [h(), nestedX, typeof nestedX]; }; } }
const nested = makeNested();
for (let i = 0; i < 1e4; ++i) {
const [thisValue, value, type] = nested();
shouldBe(thisValue, obj);
shouldBe(value, 1);
shouldBe(type, "number");
}
}
38 changes: 38 additions & 0 deletions Source/JavaScriptCore/bytecode/BytecodeList.rb
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,44 @@
operand: :offset,
}

# resolve_scope followed by get_from_scope on the resolved scope, as one instruction. `scope` is the base scope the
# resolve starts from; the resolved scope never touches a register. The metadata is the two ops' metadata laid out
# back to back (resolve first), which the baseline thunks rely on.
op :resolve_and_get_from_scope,
args: {
dst: VirtualRegister,
scope: VirtualRegister,
var: unsigned,
getPutInfo: GetPutInfo,
localScopeDepth: unsigned,
offset: unsigned,
valueProfile: unsigned,
},
metadata: {
resolveType: ResolveType,
_0: {
localScopeDepth: unsigned,
globalLexicalBindingEpoch: unsigned,
},
_1: {
lexicalEnvironment: WriteBarrierBase[JSCell],
symbolTable: WriteBarrierBase[SymbolTable],
constantScope: WriteBarrierBase[JSScope],
globalLexicalEnvironment: WriteBarrierBase[JSGlobalLexicalEnvironment],
globalObject: WriteBarrierBase[JSGlobalObject],
},
getPutInfo: GetPutInfo,
_2: {
watchpointSet: InlineWatchpointSet.*,
structureID: WriteBarrierStructureID,
},
operand: uintptr_t,
},
metadata_initializers: {
getPutInfo: :getPutInfo,
operand: :offset,
}

op :put_to_scope,
args: {
scope: VirtualRegister,
Expand Down
2 changes: 2 additions & 0 deletions Source/JavaScriptCore/bytecode/BytecodeUseDef.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ void computeUsesForBytecodeIndexImpl(const JSInstruction* instruction, Checkpoin
USES(OpResolveScope, scope)
USES(OpResolveScopeForHoistingFuncDeclInEval, scope)
USES(OpGetFromScope, scope)
USES(OpResolveAndGetFromScope, scope)
USES(OpToPrimitive, src)
USES(OpToPropertyKey, src)
USES(OpToPropertyKeyOrNumber, src)
Expand Down Expand Up @@ -521,6 +522,7 @@ void computeDefsForBytecodeIndexImpl(unsigned numVars, const JSInstruction* inst
}

DEFS(OpGetFromScope, dst)
DEFS(OpResolveAndGetFromScope, dst)
DEFS(OpCall, dst)
DEFS(OpTailCall, dst)
DEFS(OpCallDirectEval, dst)
Expand Down
59 changes: 59 additions & 0 deletions Source/JavaScriptCore/bytecode/CodeBlock.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,44 @@ bool CodeBlock::finishCreation(VM& vm, ScriptExecutable* ownerExecutable, Unlink
break;
}

case op_resolve_and_get_from_scope: {
INITIALIZE_METADATA(OpResolveAndGetFromScope)

metadata.m_watchpointSet = nullptr;
const Identifier& ident = identifier(bytecode.m_var);
ResolveType resolveType = bytecode.m_getPutInfo.resolveType();
RELEASE_ASSERT(resolveType != ResolvedClosureVar);
ASSERT(!isInitialization(bytecode.m_getPutInfo.initializationMode()));

ResolveOp op = JSScope::abstractResolve(m_globalObject.get(), bytecode.m_localScopeDepth, scope, ident, Get, resolveType, InitializationMode::NotInitialization);

// The resolve half (see op_resolve_scope).
metadata.m_resolveType = op.type;
metadata.m_localScopeDepth = op.depth;
if (op.lexicalEnvironment) {
if (op.type == ModuleVar) {
if (stronglyReferencedModuleEnvironments.add(uncheckedDowncast<JSModuleEnvironment>(op.lexicalEnvironment)).isNewEntry)
addConstant(ConcurrentJSLocker(m_lock), op.lexicalEnvironment);
metadata.m_lexicalEnvironment.set(vm, this, op.lexicalEnvironment);
} else
metadata.m_symbolTable.set(vm, this, op.lexicalEnvironment->symbolTable());
} else if (JSScope* constantScope = JSScope::constantScopeForCodeBlock(op.type, this)) {
metadata.m_constantScope.set(vm, this, constantScope);
if (op.type == GlobalProperty || op.type == GlobalPropertyWithVarInjectionChecks)
metadata.m_globalLexicalBindingEpoch = m_globalObject->globalLexicalBindingEpoch();
} else
metadata.m_globalObject.clear();

// The get half (see op_get_from_scope).
metadata.m_getPutInfo = GetPutInfo(bytecode.m_getPutInfo.resolveMode(), op.type == ModuleVar ? ClosureVar : op.type, bytecode.m_getPutInfo.initializationMode(), bytecode.m_getPutInfo.ecmaMode());
if (op.type == GlobalVar || op.type == GlobalVarWithVarInjectionChecks || op.type == GlobalLexicalVar || op.type == GlobalLexicalVarWithVarInjectionChecks)
metadata.m_watchpointSet = op.watchpointSet;
else if (op.structure)
metadata.m_structureID.set(vm, this, op.structure);
metadata.m_operand = op.operand;
break;
}

case op_put_to_scope: {
INITIALIZE_METADATA(OpPutToScope)

Expand Down Expand Up @@ -1746,6 +1784,14 @@ void CodeBlock::reconcileLLIntInlineCachesAtGCEnd()

m_metadata->forEach<OpGetFromScope>(handleGetPutFromScope);
m_metadata->forEach<OpPutToScope>(handleGetPutFromScope);
m_metadata->forEach<OpResolveAndGetFromScope>([&] (auto& metadata) {
WriteBarrierBase<SymbolTable>& symbolTable = metadata.m_symbolTable;
if (symbolTable && !vm.heap.isMarked(symbolTable.get())) {
dataLogLnIf(Options::verboseOSR(), "Clearing dead symbolTable ", RawPointer(symbolTable.get()));
symbolTable.clear();
}
handleGetPutFromScope(metadata);
});
}

// We can't just remove all the sets when we clear the caches since we might have created a watchpoint set
Expand Down Expand Up @@ -3321,6 +3367,19 @@ void CodeBlock::notifyLexicalBindingUpdate()
}
break;
}
case op_resolve_and_get_from_scope: {
auto bytecode = instruction->as<OpResolveAndGetFromScope>();
auto& metadata = bytecode.metadata(this);
ResolveType originalResolveType = metadata.m_resolveType;
if (originalResolveType == GlobalProperty || originalResolveType == GlobalPropertyWithVarInjectionChecks) {
const Identifier& ident = identifier(bytecode.m_var);
if (isShadowed(ident.impl()))
metadata.m_globalLexicalBindingEpoch = 0;
else
metadata.m_globalLexicalBindingEpoch = globalObject->globalLexicalBindingEpoch();
}
break;
}
default:
break;
}
Expand Down
1 change: 1 addition & 0 deletions Source/JavaScriptCore/bytecode/Opcode.h
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ static constexpr unsigned bitWidthForMaxBytecodeStructLength = WTF::getMSBSet(ma
macro(OpConstruct) \
macro(OpSuperConstruct) \
macro(OpGetFromScope) \
macro(OpResolveAndGetFromScope) \
Comment thread
claude[bot] marked this conversation as resolved.
macro(OpGetPrivateName) \
macro(OpNewArrayWithSpecies) \
macro(OpAsyncIteratorNext) \
Expand Down
20 changes: 20 additions & 0 deletions Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2750,6 +2750,26 @@ RegisterID* BytecodeGenerator::emitGetFromScope(RegisterID* dst, RegisterID* sco
RELEASE_ASSERT_NOT_REACHED();
}

RegisterID* BytecodeGenerator::emitResolveAndGetFromScope(RegisterID* dst, const Variable& variable, ResolveMode resolveMode)
{
// A `with` scope resolves dynamically and may run observable traps; keep those as two instructions so an OSR exit
// between them never resolves twice.
if (!canFuseResolveAndGet(variable)) {
RefPtr<RegisterID> scope = emitResolveScope(nullptr, variable);
return emitGetFromScope(dst, scope.get(), variable, resolveMode);
}
OpResolveAndGetFromScope::emit(
this,
kill(dst),
scopeRegister(),
addConstant(variable.ident()),
GetPutInfo(resolveMode, resolveType(), InitializationMode::NotInitialization, ecmaMode()),
localScopeDepth(),
0,
nextValueProfileIndex());
return dst;
}

RegisterID* BytecodeGenerator::emitPutToScope(RegisterID* scope, const Variable& variable, RegisterID* value, ResolveMode resolveMode, InitializationMode initializationMode)
{
switch (variable.offset().kind()) {
Expand Down
9 changes: 9 additions & 0 deletions Source/JavaScriptCore/bytecompiler/BytecodeGenerator.h
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,15 @@ namespace JSC {
RegisterID* emitResolveConstantLocal(RegisterID* dst, const Variable&);
RegisterID* emitResolveScope(RegisterID* dst, const Variable&);
RegisterID* emitGetFromScope(RegisterID* dst, RegisterID* scope, const Variable&, ResolveMode);
// emitResolveScope + emitGetFromScope as one instruction when the resolve is a static non-local lookup.
RegisterID* emitResolveAndGetFromScope(RegisterID* dst, const Variable&, ResolveMode);
// A non-local lookup with no `with` scope anywhere on the chain (own or enclosing functions): the resolved
// scope is never a `with` object, so a call through it takes `this` as undefined, the same as to_this makes of
// the scope, and the resolve never runs a trap.
bool canFuseResolveAndGet(const Variable& variable)
{
return variable.offset().kind() == VarKind::Invalid && resolveType() != Dynamic && !(lexicallyScopedFeatures() & TaintedByWithScopeLexicallyScopedFeature);
}
RegisterID* emitPutToScope(RegisterID* scope, const Variable&, RegisterID* value, ResolveMode, InitializationMode);
RegisterID* emitPutToScopeDynamic(RegisterID* scope, const Identifier&, RegisterID* value, ResolveMode, InitializationMode);

Expand Down
Loading
Loading