Skip to content

fix(script): drop AST after script execution#4876

Merged
jedel1043 merged 15 commits intoboa-dev:mainfrom
ashnaaseth2325-oss:fix/script-ast-memory-leak
Mar 6, 2026
Merged

fix(script): drop AST after script execution#4876
jedel1043 merged 15 commits intoboa-dev:mainfrom
ashnaaseth2325-oss:fix/script-ast-memory-leak

Conversation

@ashnaaseth2325-oss
Copy link
Copy Markdown

@ashnaaseth2325-oss ashnaaseth2325-oss commented Mar 5, 2026

Context

This PR extends the idea introduced in #4855.

PR #4855 fixed a memory retention issue for modules, where the parsed AST and source text were kept in memory even after module initialization.

This PR applies the same idea to scripts, ensuring that the parsed AST is released once script execution has completed.

Summary

This PR prevents scripts from keeping their parsed AST after compilation.

Right now Script::Inner stores the full AST in the source field:

struct Inner {
    realm: Realm,
    source: boa_ast::Script,
    source_text: SourceText,
    codeblock: GcRefCell<Option<Gc<CodeBlock>>>,
}

The AST is only needed while generating bytecode in Script::codeblock().
After compilation it is not used anymore, but it still stays in Script::Inner.

Function objects created from the script keep a reference to the script (OrdinaryFunction::script_or_module). Because of this, the script can stay alive for a long time, which also keeps the whole AST in memory.

Steps to Reproduce

In a long-running program (for example a REPL or server), repeatedly run scripts that define functions:

let script = Script::parse(Source::from_bytes(src), None, &mut context)?;
script.evaluate(&mut context)?;

If those functions are stored on the global object, they keep the script alive.
Since the script holds the AST, memory usage keeps growing as more scripts run.

Impact

This mostly affects long-running programs that embed Boa.

Every script that defines functions can keep its AST in memory forever.
AST structures are usually much bigger than the source code, so memory usage can grow over time.

Modules already drop their AST after linking, but scripts did not.

Fix

Script::Inner::source is changed to:

source: RefCell<Option<boa_ast::Script>>

The AST is used during compilation and then removed once the CodeBlock is created:

*self.inner.source.borrow_mut() = None;

Result

After this change, the AST is dropped once compilation finishes.

Scripts now behave consistently with modules after the change introduced in #4855.

The AST is only required during parsing and compilation. Once the script finishes execution and the result is consumed, it can be safely released to reduce memory usage in long-running runtimes.

ashnaaseth2325-oss added 2 commits March 5, 2026 13:46
`Script::Inner` held `source: boa_ast::Script` forever. Every function
object created during execution stores `OrdinaryFunction::script_or_module`,
which is a strong `Gc` reference back to `Script::Inner`. For global
functions this keeps the full parsed AST in memory indefinitely.

Change the field to `RefCell<Option<boa_ast::Script>>` and set it to
`None` at the end of `codeblock()`, mirroring the same fix already
applied to modules after linking.

Signed-off-by: ashnaaseth2325-oss <ashnaaseth2325@gmail.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 5, 2026

Test262 conformance changes

Test result main count PR count difference
Total 52,963 52,963 0
Passed 49,666 49,666 0
Ignored 2,284 2,284 0
Failed 1,013 1,013 0
Panics 0 0 0
Conformance 93.77% 93.77% 0.00%

Tested main commit: 4acbac37250fbf3a0461fbff37b1b3019c7f1482
Tested PR commit: e1b71c05c9c9845dd8328963bd1f91fe9aa25aba
Compare commits: 4acbac3...e1b71c0

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 5, 2026

Codecov Report

❌ Patch coverage is 92.00000% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.45%. Comparing base (6ddc2b4) to head (e1b71c0).
⚠️ Report is 765 commits behind head on main.

Files with missing lines Patch % Lines
core/engine/src/script.rs 92.00% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main    #4876       +/-   ##
===========================================
+ Coverage   47.24%   57.45%   +10.21%     
===========================================
  Files         476      556       +80     
  Lines       46892    60714    +13822     
===========================================
+ Hits        22154    34886    +12732     
- Misses      24738    25828     +1090     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ashnaaseth2325-oss
Copy link
Copy Markdown
Author

Hello @zhuzhu81998
#4855 handled the module path (SourceTextModule::initialize_environment).
This PR is trying to apply the same idea to the script execution path (Script::evaluate_async_with_budget), since scripts can also retain their parsed AST after execution.
So the goal here is basically to mirror the module fix but for regular scripts.

@ashnaaseth2325-oss
Copy link
Copy Markdown
Author

ashnaaseth2325-oss commented Mar 5, 2026

Hello @hansl @jedel1043
This PR was trying to apply the same idea to scripts. It looks like dropping the AST here causes some test262 regressions (+97 failures), so the placement is probably too early.
I'm looking into where it could be safely released instead.

Comment thread core/engine/src/script.rs Outdated
source: boa_ast::Script,
source: RefCell<Option<boa_ast::Script>>,
source_text: SourceText,
codeblock: GcRefCell<Option<Gc<CodeBlock>>>,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think a better approach would be to make this item an enum like

enum ScriptPhase {
    Ast(boa_ast::Script),
    Codeblock(Gc<CodeBlock>)
}

Then we transition through the phases of compilation of the script in codeblock.
This saves some nice bytes of memory representation.

@jedel1043 jedel1043 added A-Bug Something isn't working A-Internal Changes that don't modify execution behaviour labels Mar 5, 2026
@ashnaaseth2325-oss
Copy link
Copy Markdown
Author

Hello @jedel1043
Thanks for the suggestion! I refactored the implementation to use an enum-based phase (ScriptPhase) as you described, transitioning from AstCodeblock during compilation. The project builds and tests pass locally.

Comment thread core/engine/src/script.rs Outdated
Comment on lines 126 to 128
if let ScriptPhase::Codeblock(codeblock) = &*phase {
return Ok(codeblock.clone());
}
Copy link
Copy Markdown
Member

@jedel1043 jedel1043 Mar 6, 2026

Choose a reason for hiding this comment

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

Let's make use of Rust features :)

let phase = self.inner.phase.borrow();
let source = match &*phase {
    ScriptPhase::Codeblock(codeblock) => return Ok(codeblock.clone()),
    ScriptPhase::Ast(source) => source
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I edited this btw, at first I used a clone but I realized that would clone the whole AST, which we don't want

@ashnaaseth2325-oss
Copy link
Copy Markdown
Author

Hello @jedel1043
Thanks for the suggestion! I updated the code to follow the pattern you suggested (using borrow() and avoiding cloning the AST). I’m checking the CI failures now to see what still needs fixing.

@jedel1043 jedel1043 enabled auto-merge March 6, 2026 06:53
Copy link
Copy Markdown
Member

@jedel1043 jedel1043 left a comment

Choose a reason for hiding this comment

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

Looks good!

@jedel1043 jedel1043 added this pull request to the merge queue Mar 6, 2026
Merged via the queue into boa-dev:main with commit 70a6df2 Mar 6, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Bug Something isn't working A-Internal Changes that don't modify execution behaviour

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants