[JSC] A suspended generator or async function keeps its scopes' SymbolTables when its code is generated again
A closure made in the body of a loop can go on reading the binding of an earlier iteration:
async function main() {
for (let iter = 0; iter < 3; iter++) {
const box = { iter };
if (!iter)
await somethingLong(); // all code is thrown away meanwhile, or just this function's gets old
for (let k = 0; k < 20000; k++) {
if ([0].reduce(acc => acc + box.iter, 0) !== iter)
throw new Error("read iteration " + box.iter + "'s box in iteration " + iter); // thrown in iteration 1
}
}
}
It needs the function to resume in code that was generated again while it was suspended: after VM::deleteAllCode, or
after its CodeBlock was jettisoned for old age and its unlinked code was dropped too (Heap::deleteAllUnlinkedCodeBlocks,
UnlinkedFunctionExecutable::clearCode, or the collector clearing the weak edge to it). Correct with the DFG off.
Cause
DFG::ByteCodeParserfoldsresolve_scopeof a closure variable to a constant scope when the scope's SymbolTable has
only ever had one environment (SymbolTable::singleton()), under a watchpoint thatSymbolTable::notifyCreationfires
when a second environment is made.get_from_scopethen folds the variable under its own watchpoint set in the same
table.- The table in question is the clone that
CodeBlock::setConstantRegistersmakes of the SymbolTable constant in the
enclosing function's unlinked code.JSGlobalObject::symbolTableCachefinds the clone again for a later CodeBlock linked
from the same unlinked code, keyed by the unlinked SymbolTable, so re-linking is fine. - When the unlinked code is generated (or decoded) again its SymbolTable constants are new objects, the cache misses, and
the new CodeBlock gets new clones. An activation that was suspended across this still holds the environment it made from
the old clone. Closures made over that environment after the resume are compiled against the old clone's inference,
which still says "one environment". The next iteration's environment comes from the new clone and notifies the new
clone. Nothing ever tells the compiled closure code, which all closures from that site share. - Only an activation that is not on the stack can span a regeneration, so only generators, async functions, async arrow
functions, async generators and module bodies (top-level await) are affected. Only the first activation since the code
was generated: a second one has invalidated the inference already.
Fix
For the body of a generator, an async function or a module, CodeBlock::setConstantRegisters finds the clone of a
SymbolTable constant by whose code it is and which constant (JSGlobalObject::resumableCodeSymbolTableClones(), per
realm, weak in the clone: the UnlinkedFunctionExecutable, which is what code is dropped from and what clones are shared
by today, or the ModuleProgramExecutable, and the constant's index) instead of by the constant itself
(symbolTableCache, which everything else keeps using). When the clone that is found was made of another constant, the
code has been generated or decoded again since:
- if it describes the scope part of the new constant exactly (
SymbolTable::isCloneOfScopePartOf: kind of scope,
offsets, arguments, every entry's offset and attributes, private names, type profiling), it becomes the new constant's
clone (SymbolTable::adoptOriginal), so the environments the new code makes reach the inference that older code holds; - otherwise everything inferred through it is given up (
SymbolTable::invalidateInferencesOfAbandonedClone: the
singleton and every variable's watchpoint set, including ones nobody watches yet), and the new clone starts with its
singleton invalidated, because the scope has environments it did not see being made. This is rare: the code of such a
body is generated the way it was when it first ran (below), so the shape only differs when two executables that share
the unlinked function are pinned to different modes, when only one of the two constants was prepared for the type
profiler, or when the owner in the key has died and its address is another owner's by now. Every outcome is
conservative (the older environments' inferences are gone and the new clone never claims to be a singleton, or an
unrelated scope of the same shape shares a clone); a debugger would show the older scope's name for a shared one.
Acting where code is linked covers every way unlinked code goes away with one check.
The per-variable watchpoint sets of such bodies have been pre-invalidated at link time since 323583 (put_to_scope in
CodeBlock::finishCreation), which covered the assignment half of this for function bodies; that is left as it is.
Also: a module with a top-level await
ModuleProgramExecutable::getUnlinkedCodeBlocktook the realm's currentCodeGenerationModeevery time. A module that
is suspended at a top-level await has its registers in a generator frame; when its code was generated again for the
debugger (or without it) in the meantime, every variable moved between register and scope and the resumed code read
whatever was in the slots:slow_path_incon an empty value, a crash. From the moment its body is about to run
(ScriptExecutable::newCodeBlockFor), such a module now keeps the mode that code was generated with, as the body of a
generator does; the two shareScriptExecutable::codeGenerationModeForResumableBody.ScriptExecutable::clearCodedropped the clone of the module environment's SymbolTable andgetUnlinkedCodeBlock
cloned the new constant again, while the module's oneJSModuleEnvironmentkeeps the clone it was made from: after the
resume, the body's assignments to top-level bindings notified watchpoint sets that nothing compiled against the
environment was watching (let x = 1; function f() { return x }...await...x = 2: a compiledfwent on returning
1). The clone now stays with the executable and is adopted by, or invalidated against, the new constant like the clones
above.
Tests
stress/suspended-activation-keeps-scope-inference-when-code-is-generated-again.js: generator, async function, async
arrow function, async generator, afor (let ...)binding, a nested closure over a binding that is assigned again, a
body whose code is generated again twice, and the function scope of later activations;$vm.deleteAllCodeWhenIdle()
while suspended. Default tiers, DFG and FTL forced with the concurrent JIT off, and the bytecode cache. Seven of the
eight shapes read every value wrong in the iterations after the resume without the change (the last is there to stay
right).stress/suspended-module-resumes-in-code-that-is-generated-again.js: a module suspended at a top-level await in a loop
while$vm.enableDebuggerModeWhenIdle()and then$vm.disableDebuggerModeWhenIdle()generate all code again; closures
over the loop's bindings and a function over a top-level binding that is assigned again after each resume. Crashes
without the pin; with the pin and without the rest, both read wrong values.