Skip to content

[JSC] A store decided from the Structure alone calls the put() or defineOwnProperty() override of the class - #764

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/8ddf1804/dfg-put-fold-overrides-put
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/8ddf1804/dfg-put-fold-overrides-put

Conversation

@robobun

@robobun robobun commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Three JSC paths store to a property from the Structure alone and skip a put() or defineOwnProperty() override: the DFG's PutByStatus::computeFor(StructureSet) (bytecode/PutByStatus.cpp:364), the for-in store (op_enumerator_put_by_val) and the JSON.parse reviver walk.
  • In Bun, process.env.TZ = zone from a hot function stops changing the time zone (call 108 stored Asia/Tokyo offset 0).
  • The DFG abstract interpreter trusts the same status. A PutById that it keeps counts as free of side effects, so a later load returns another property's value.

Fix

  • Structure gets one bit, classInterceptsOwnPropertyStores: the class overrides put() or defineOwnProperty(), and not getOwnPropertySlot().
  • PutByStatus returns LikelyTakesSlowPath for it (the gate from [JSC] A store to Error.stackTraceLimit from JIT code keeps updating the stack trace limit #640). The five copies of the for-in store test one named mask that includes it. The reviver walk calls createDataProperty().
  • Verified: three new JSTests/stress tests. Fork main fails 50 of 51 runs (17 modes each), this branch none. Each of the 7 hunks, reverted alone, fails one test.

Background

Downsides

  • Structure creation: +20 instructions in the creating constructor (149 to 169), +5 in the transition constructor (objdump, x64 release).
  • JSON.parse with a reviver: +6 instructions per revived property.
  • Plain objects get 0 new instructions per store and per for-in iteration. A hot process.env store now calls put() each time.
Notes

Repro in jsc ($vm.createObjectDoingSideEffectPutWithCorrectSlotStatus() is new in this PR: put() and defineOwnProperty() convert the value to a string, and put() calls slot.disableCaching()):

var object = $vm.createObjectDoingSideEffectPutWithCorrectSlotStatus();
object.x = 0;
function store(v) { object.x = v; }
function load() { return object.x; }
noInline(store); noInline(load);
for (var i = 0; i < 100000; ++i) { store(i); if (load() !== String(i)) throw new Error("iteration " + i + ": " + typeof object.x); }

Fork main throws at iteration 99 with --useConcurrentJIT=0. It passes with --useDFGJIT=0, --useAccessInlining=0 or --useJIT=0.

Repro in Bun (release 1.4.3-canary.1, Node v26.3.0 prints nothing):

function setTZ(zone) { process.env.TZ = zone; }
function readTZ() { return process.env.TZ; }
for (let i = 0; i < 20000; i++) {
  const zone = i & 1 ? "UTC" : "Asia/Tokyo";
  setTZ(zone); readTZ();
  const off = new Date(2020, 0, 1).getTimezoneOffset();
  if (off !== (i & 1 ? 0 : -540)) { console.log("call", i, "stored", process.env.TZ, "offset", off); break; }
}

How each path gets to the raw store

  • DFG, Replace: the base has a proved structure (a constant, or a load before the store). A load of the same property from JIT code creates the replacement watchpoint set, and the next store fires it. computeFor then returns a Replace variant and tryFoldAsPutByOffset emits PutByOffset.
  • DFG, Transition: a store that adds a property needs no watchpoint set, only a proved structure and an existing transition.
  • DFG, two structures with the property at two offsets: the FTL emits MultiPutByOffset. The DFG tier keeps the PutById, but the abstract interpreter already called didFoldClobberWorld(). put() runs toString() on the value, that code changes another object, and a later GetByOffset on that object has lost its CheckStructure. The test gets "q" where undefined is correct.
  • DFG, direct store (class field): the slow path calls defineOwnProperty() when the class overrides it (CommonSlowPaths::canPutDirectFast). The fold skipped it.
  • For-in store: for (name in object) object[name] = value writes the slot when the structure matches the enumerator and has none of two bits. This happens in the LLInt already, so --useJIT=0 fails too.
  • Reviver walk: the reviver can put any object in the tree, and the walk then stores the revived value with putDirectOffset.

The bit

  • Definition: the cell is an object, the class does not set OverridesGetOwnPropertySlot, and it sets OverridesPut or its method table has a defineOwnProperty other than JSObject::defineOwnProperty.
  • A class that also overrides getOwnPropertySlot() (JSArray, JSFunction, RegExpObject, ErrorInstance, arguments, the global object) is as before. PutByStatus has its own test for that flag, with the global object exempt. The put() of those classes guards names that a for-in store does not reach.
  • A temporary log over 8,824 JSTests scripts (stress, wasm, es6, microbenchmarks) printed only the two $vm classes.
  • sizeof(Structure) is 112 bytes before and after (x64). Bit 11 of m_bitField was free. [JSC] JSObject::makePropertiesImmutable(): an object's own properties and prototype stop changing, and its property attributes stay as they are #759 uses bit 10.

Verification (Linux x64)

Cost measurements (release jsc, fork main plus the test class against this branch)

  • For-in store on a plain object: LLIntAssembly.h differs in the immediate at 3 sites (testl $262160, 16(%rdx) to testl $264208, 16(%rdx)). Baseline, DFG and FTL code for function f(o) { for (var k in o) o[k] = 1; } has the same size in both builds (1120, 1184 and 1472 bytes). The debug disassembly differs in that immediate only (testl $0x40010 to testl $0x40810).
  • PutById on a plain object with a cached load: same DFG graph, same code size (416 bytes), still PutByOffset.
  • For-in store over named properties of an Array, a function and a RegExp (36,000 stores, --useJIT=0, gdb breakpoint on slow_path_enumerator_put_by_val): 12,000 hits before and after. A plain object: 0 hits before and after.
  • size jsc (both built in the same directory): text 43952121 to 43951353, total 44732804 to 44732804.
  • arm64 was not run here. Neither mask is an ARM64 logical immediate, so both take the same move and tst sequence.
  • perf, valgrind and bloaty are not installed on the machine, so the instruction counts come from objdump and from the JIT dumps.

Not in this PR

@robobun

robobun commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

How I reproduced it:

  • Bun release 1.4.3-canary.1 on Linux x64: the setTZ / readTZ loop from the Notes prints call 108 stored Asia/Tokyo offset 0. Node v26.3.0 prints nothing. BUN_JSC_useDFGJIT=0 or BUN_JSC_useAccessInlining=0 prints nothing.
  • jsc built from fork main 4fde158 plus the $vm test class of this PR: the three new tests in JSTests/stress throw (50 of 51 runs of run-jsc-stress-tests). Built from this branch: 51 of 51 pass.
  • Bun debug build of process: let inline caches work on process.env and process.argv bun#44356 merged with main: its it.todo script prints 299390 with the pinned WebKit and 0 with the preview build of this PR.

This PR: #764

…ineOwnProperty() override of the class

Three paths decide a store from the Structure and write the slot of the
property. They have no PutPropertySlot to ask, so they never learn that the
class of the object overrides put() or defineOwnProperty():

- PutByStatus::computeFor(StructureSet). The DFG folds the store into a
  PutByOffset. The abstract interpreter reads the same status and treats a
  PutById that it does not fold as free of side effects.
- op_enumerator_put_by_val in OwnStructureMode, in its five copies.
- The JSON.parse reviver walk.

Structure gets one bit, classInterceptsOwnPropertyStores. The constructor
sets it when the class overrides put() or defineOwnProperty() and does not
override getOwnPropertySlot(). PutByStatus returns LikelyTakesSlowPath for
such a structure. The five copies of the for-in store test one named mask
that includes the bit. The reviver walk calls createDataProperty().

A class that also overrides getOwnPropertySlot() does not get the bit.
PutByStatus already tests for that flag, and the global object stays exempt.

$vm gets ObjectDoingSideEffectPutWithCorrectSlotStatus as a test subject.
Its put() and defineOwnProperty() convert the value to a string.
@robobun
robobun force-pushed the robobun/8ddf1804/dfg-put-fold-overrides-put branch from 6ee4f72 to 2d86805 Compare October 3, 2026 03:41
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown

Preview build of 2d86805: autobuild-preview-pr-764-2d868053

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants