Skip to content

[JSC] CodeBlock aging: refresh the execution-counter snapshot on every look, not only past the TTL - #566

Merged
Jarred-Sumner merged 2 commits into
mainfrom
claude/aging-snapshot-every-look
Sep 5, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
claude/aging-snapshot-every-look

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Follow-up to #542/#546/#560 (this was pushed to #560's branch after it had merged).

The bug

CodeBlock::shouldJettisonDueToOldAge() returned early on timeSinceCreation() < ttl before reading the block's execution counter, so a collection that found a block inside its TTL/lease kept whatever counter snapshot it had from before. The next collection then compared the current counter against that stale snapshot, saw it had "moved" (it moved during the last burst of work, not since), and renewed the lease of code that hadn't run since.

With an embedder whose idle collections come as a pair — Bun collects ~10 s and ~75 s after the heap goes quiet — that renewal is never expired: idle GC #1 finds every recently-run DFG/Baseline block too young (no snapshot), idle GC #2 finds every counter "moved" (renew, lease pushed 2×TTL into the future), and there is no GC #3.

Instrumented on a Claude Code session (compiled CLI, 20 turns, then idle), optimizing blocks seen by the two idle collections: before tooYoung=1250 at #1 → counterMoved=1088, agedOut=0 at #2; after tooYoung=257 → agedOut=1159.

The change

  • Check the execution counter and refresh m_previousCounter on every look, then apply the TTL. "Moved" now always means "since the previous collection that looked".
  • An optimizing block that the old-age check let go this cycle is jettisoned with JettisonDueToOldAge rather than JettisonDueToWeakReference (an unmarked optimizing block always matched the latter), so jettison() takes its old-age path (no exit-site tally; baseline code cache released per [JSC] Return unused MarkedBlock pages after sweeping; let idle optimized code age out #542).

Actively running code is unaffected: its counter still moves between looks and renews exactly as before (codeblock-aging-execution-count.js passes — it asserts no jettison of running functions across 30 GCs).

Effect (Claude Code, after Bun's second idle collection)

before after
CodeBlocks alive 2,482 (Baseline 1,183 / DFG 1,087) 432 (225 / 107)
their estimated size 7.3 MB (+ ~15 MB metadata / JITData / IC stubs off-heap) 1.1 MB
anonymous RSS at idle +90 s 190–196 MB 179 MB

…, not only once the block is past its TTL

shouldJettisonDueToOldAge() returned early while a block was inside its TTL/lease without recording the block's current
execution counter. The next collection then compared against a snapshot from before the last burst of work, saw the
counter had "moved", and renewed the lease of code that had not run since - and with an embedder whose idle
collections come as a pair (Bun: ~10 s and ~75 s after the heap goes quiet) that renewal was never expired: the first
idle collection finds every recently-run DFG/Baseline block too young, the second finds its counter moved.

Check the counter first and refresh the snapshot on every look; only then apply the TTL. "Moved" now always means
"since the previous collection". Actively running code is unaffected (codeblock-aging-execution-count.js).

Also: an optimizing block that the old-age check let go this cycle is now jettisoned with JettisonDueToOldAge rather
than JettisonDueToWeakReference (an unmarked optimizing block always looked like the latter), so jettison() takes its
old-age path.

Claude Code (compiled, 20-turn session, then idle), CodeBlocks alive after Bun's second idle collection:
before 2,482 (Baseline 1,183 / DFG 1,087, ~7.3 MB + their metadata/JITData), after 432 (225 / 107, ~1.1 MB);
anonymous RSS at idle 190-196 MB -> 179 MB.

(cherry picked from commit 794bcc3ee56c78e5e3217bdf2139d34f754411c5)
(cherry picked from commit 8aa8f4e)
…ited

Heap reads ApproximateTime once when a collection begins (m_currentGCStartApproximateTime, next to the MonotonicTime it
already takes) and shouldJettisonDueToOldAge() measures the TTL, renews leases and compares against
lastActiveCollectionTime() with that value instead of calling ApproximateTime::now() for every CodeBlock the
collection visits. Same results at collection granularity.
@coderabbitai

coderabbitai Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: f62995b3-47f9-4d38-aa5e-2f46c2978774

📥 Commits

Reviewing files that changed from the base of the PR and between 2e2aa22 and d8d957b.

📒 Files selected for processing (4)
  • Source/JavaScriptCore/bytecode/CodeBlock.cpp
  • Source/JavaScriptCore/heap/Heap.cpp
  • Source/JavaScriptCore/heap/Heap.h
  • Source/JavaScriptCore/runtime/ScriptExecutableInlines.h

Included review availability: 1 review is currently available. Your included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.


Walkthrough

Changes

Code block lifecycle

Layer / File(s) Summary
GC timestamp caching
Source/JavaScriptCore/heap/Heap.h, Source/JavaScriptCore/heap/Heap.cpp, Source/JavaScriptCore/bytecode/CodeBlock.cpp
Heap caches the approximate GC start time. GC activity tracking and code block aging reuse this timestamp.
Execution-count aging and lease renewal
Source/JavaScriptCore/bytecode/CodeBlock.cpp
Execution activity is evaluated before TTL expiration. Observed execution renews the lease.
Aged code block jettison
Source/JavaScriptCore/runtime/ScriptExecutableInlines.h
Aged code blocks are explicitly jettisoned with the old-age reason before weak-reference handling.

Merge Risk: ⚪ Minimal · up to d8d95

Code-block aging now uses a collection-level timestamp, refreshes activity snapshots between collections, and explicitly labels aged-block cleanup. The change is ready to merge with no identified current-head risk.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description provides a detailed bug explanation, implementation summary, behavioral impact, and measurements. However, it omits required template information, including a Bugzilla URL, the reviewe… Add the associated Bugzilla URL and bug title, include the required "Reviewed by NOBODY (OOPS!)." line or actual reviewer information, and list each changed file with the relevant functions or classes.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: refreshing the execution-counter snapshot on every CodeBlock aging check instead of only after the TTL. It is specific and related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description provides a detailed bug explanation, implementation summary, behavioral impact, and measurements. However, it omits required template information, including a Bugzilla URL, the reviewer line, and a changed-file/function list.

  • Fix all pre-merge checks with AI

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


Comment @coderabbitai help to get the list of available commands.

Comment on lines 1431 to 1432
if (hasCounter && currentCount != m_previousCounter) {
m_previousCounter = currentCount;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Moving the counter snapshot ahead of the TTL gate means m_previousCounter = currentCount is now written during marking on every look, so updateActivity()'s later m_previousCounter < count test is always false and m_unlinkedCode->resetAge() is never called for LLInt/Baseline/DFG blocks; under useUnlinkedCodeBlockJettisoning (mini/no-JIT mode) the UnlinkedCodeBlock's age hits maxAge after ~7 GCs and dies the moment its CodeBlock ages out, forcing a full re-parse on the next call where the base branch kept the bytecode cached. Fix: have updateActivity() decide activity from the flag this pass already computed (e.g. reset age whenever shouldJettisonDueToOldAge renewed the lease) instead of re-comparing m_previousCounter.

Extended reasoning...

shouldJettisonDueToOldAge() runs from the executable's visitChildren during marking; with the reorder, an unmarked LLInt/Baseline block whose counter moved hits line 1431 and stores m_previousCounter = currentCount before the TTL early-out. The block is then strongly visited (line 1298 returns true), added to the CodeBlock set (line 1278), and at GC end reconcileWeakReferencesAtGCEnd → updateActivity() (guarded by VM::useUnlinkedCodeBlockJettisoning(), true when !useJIT() or forceMiniVMMode) reads the same counter into count and tests m_previousCounter < count at CodeBlock.cpp:1988 — now always equal, so resetAge() at 1990 is skipped. On the base branch the TTL gate returned before touching m_previousCounter, so updateActivity() still saw the previous GC's snapshot, observed movement, and reset the UnlinkedCodeBlock's age each collection (only the single renewal GC per lease skipped it). After the change nothing ever resets m_age; UnlinkedCodeBlock::visitChildren (UnlinkedCodeBlock.cpp:103) increments it every GC to maxAge=7, and once the CodeBlock finally…

Verification: normal — the reorder introduces a regression the base does not have, in the VM::useUnlinkedCodeBlockJettisoning() configuration (VM.h:766-769 → !useJIT() || forceMiniVMMode() || Options::useUnlinkedCodeBlockJettisoning()). Mechanism, verified against both branches: - ScriptExecutable::visitCodeBlockEdge (ScriptExecutable.cpp:565) calls shouldVisitStrongly() during marking, which at…

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
d8d957b2 autobuild-preview-pr-566-d8d957b2 2026-09-05 00:26:51 UTC

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code review found no new issues

No new issues were found in this update; 1 finding from earlier reviews is still open above.

@Jarred-Sumner
Jarred-Sumner merged commit 983055f into main Sep 5, 2026
48 checks passed
This was referenced Sep 5, 2026
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.

1 participant