Skip to content
Merged
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
9 changes: 7 additions & 2 deletions Jint.Tests.Test262/Test262Test.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,19 @@ private static void ExecuteTest(Engine engine, Test262File file)
if (file.Type == ProgramType.Module)
{
var specifier = "./" + Path.GetFileName(file.FileName);
engine.Modules.Add(specifier, builder => builder.AddSource(file.Program));
engine.Modules.Add(specifier, builder => builder
.WithOptions(static options => options with { RetainFunctionSourceText = true })
.AddSource(file.Program));
engine.Modules.Import(specifier);
}
else
{
var script = Engine.PrepareScript(file.Program, source: file.FileName, options: new ScriptPreparationOptions
{
ParsingOptions = ScriptParsingOptions.Default with { Tolerant = false, AllowReturnOutsideFunction = false },
// Retain source text so Function.prototype.toString() conformance tests
// (built-ins/Function/prototype/toString/*) validate the real-source output, which is
// opt-in since the engine no longer retains source by default (issue #2560).
ParsingOptions = ScriptParsingOptions.Default with { Tolerant = false, AllowReturnOutsideFunction = false, RetainFunctionSourceText = true },
});

engine.Execute(script);
Expand Down
41 changes: 39 additions & 2 deletions Jint.Tests/Runtime/FunctionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -772,12 +772,12 @@ function fn() {
(
)=>0
""")]
public void ToStringShouldProduceStandardsCompliantResultByDefault(string code, string expectedResult)
public void ToStringShouldProduceStandardsCompliantResultWhenSourceTextRetained(string code, string expectedResult)
{
code = Regex.Replace(code, @"\r\n?", "\n", RegexOptions.CultureInvariant); // normalize line endings to '\n'
expectedResult = Regex.Replace(expectedResult, @"\r\n?", "\n", RegexOptions.CultureInvariant); // normalize line endings to '\n'

var returnValue = new Engine().Evaluate(code);
var returnValue = new Engine(options => options.RetainFunctionSourceText()).Evaluate(code);

var actualResults = Assert.IsType<JsArray>(returnValue);
Assert.Equal(2u, actualResults.Length);
Expand All @@ -788,4 +788,41 @@ public void ToStringShouldProduceStandardsCompliantResultByDefault(string code,
var actualResult2 = Assert.IsType<JsString>(actualResults[1]).ToString();
Assert.Same(actualResult1, actualResult2);
}

[Fact]
public void ToStringReturnsNativeCodePlaceholderByDefault()
{
// By default the source text is not retained (to save memory), so toString() returns the
// native-code placeholder rather than the original source. See issue #2560.
var engine = new Engine();

Assert.Equal("function fn() { [native code] }", engine.Evaluate("function fn() { return 0; } fn.toString();").AsString());
Assert.Equal("function foo() { [native code] }", engine.Evaluate("var foo = function () {}; foo.toString();").AsString());
Assert.Equal("function () { [native code] }", engine.Evaluate("(() => 0).toString();").AsString());
}

[Fact]
public void ToStringReturnsSourceTextWhenRetainedViaParsingOptions()
{
// Opt in to source-text retention through parsing options (covers the prepared-script path too).
var engine = new Engine();
var parsingOptions = new ScriptParsingOptions { RetainFunctionSourceText = true };

Assert.Equal(
"function fn() { return 0; }",
engine.Evaluate("function fn() { return 0; } fn.toString();", parsingOptions).AsString());

var prepared = Engine.PrepareScript(
"function fn() { return 42; } fn.toString();",
options: new ScriptPreparationOptions { ParsingOptions = parsingOptions });
Assert.Equal("function fn() { return 42; }", new Engine().Evaluate(prepared).AsString());
}

[Fact]
public void PreparedScriptDoesNotRetainSourceTextByDefault()
{
// A prepared script must not retain its source by default: toString() falls back to native code.
var prepared = Engine.PrepareScript("function fn() { return 0; } fn.toString();");
Assert.Equal("function fn() { [native code] }", new Engine().Evaluate(prepared).AsString());
}
}
79 changes: 70 additions & 9 deletions Jint.Tests/Runtime/GarbageCollectionTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
namespace Jint.Tests.Runtime;
using System.Text;
using Acornima.Ast;

namespace Jint.Tests.Runtime;

// This needs to run without any parallelization because it uses
// garbage collector metrics which cannot be isolated.
Expand Down Expand Up @@ -49,17 +52,75 @@ function allocate(runAllocation) {
---
Acceptable : {BytesToString(usedMemoryBytesBaseline + epsilon)}
""");
return;
}

[Fact]
public void PreparedScriptsDoNotRetainSourceTextByDefault()
{
// Regression test for #2560: by default a prepared script must not keep the full source text alive
// (it was retained per function node to back Function.prototype.toString()). With many large cached
// scripts this caused hundreds of MB of duplicated source strings. Holding N large scripts, the
// retaining variant must keep ~N * sourceSize more bytes alive than the non-retaining default.
//
// Each script is a tiny function wrapped around a large, unique comment: the source string is big
// (~commentChars * 2 bytes, UTF-16) while the AST is tiny, so the measured delta isolates the source.

const int count = 25;
const int commentChars = 400_000; // ~800 KB of source per script

var retained = MeasurePreparedHeap(count, commentChars, retainSourceText: true);
var notRetained = MeasurePreparedHeap(count, commentChars, retainSourceText: false);

// Theoretical savings ≈ count * commentChars * 2 (UTF-16). Assert at least half to absorb noise.
var minimumSavings = (long) count * commentChars; // bytes; conservative lower bound (< chars * 2)
var actualSavings = retained - notRetained;
Assert.True(
actualSavings > minimumSavings,
userMessage: $"""
Disabling RetainFunctionSourceText did not free the expected source text.
Retained : {BytesToString(retained)}
Not retained : {BytesToString(notRetained)}
Savings : {BytesToString(actualSavings)}
Expected : > {BytesToString(minimumSavings)}
""");

static long MeasurePreparedHeap(int count, int commentChars, bool retainSourceText)
{
var options = new ScriptPreparationOptions
{
ParsingOptions = new ScriptParsingOptions { RetainFunctionSourceText = retainSourceText },
};

var prepared = new List<Prepared<Script>>(count);
for (var i = 0; i < count; i++)
{
// The source string is built inline and never stored: only a retaining prepared script keeps
// it reachable. With retention off it becomes collectable once parsing completes.
prepared.Add(Engine.PrepareScript(BuildLargeScript(i, commentChars), options: options));
}

static string BytesToString(long bytes)
=> $"{(bytes / 1024.0 / 1024.0),6:0.0} MB";
var used = CurrentlyUsedMemory();
GC.KeepAlive(prepared);
return used;
}

static long CurrentlyUsedMemory()
static string BuildLargeScript(int seed, int commentChars)
{
// Just try to ensure that everything possible gets collected.
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
var currentlyUsed = GC.GetTotalMemory(forceFullCollection: true);
return currentlyUsed;
var sb = new StringBuilder(commentChars + 64);
sb.Append("function big").Append(seed).Append("() {\n /* ").Append(seed).Append(' ');
sb.Append('x', commentChars); // unique-ish, large comment body kept only in the source text
sb.Append(" */\n return 0;\n}\n");
return sb.ToString();
}
}

private static long CurrentlyUsedMemory()
{
// Just try to ensure that everything possible gets collected.
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
return GC.GetTotalMemory(forceFullCollection: true);
}

private static string BytesToString(long bytes)
=> $"{(bytes / 1024.0 / 1024.0),6:0.0} MB";
}
4 changes: 3 additions & 1 deletion Jint/Engine.Ast.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,13 @@ public static Prepared<Module> PrepareModule(string code, string? source = null,
private sealed class AstAnalyzer
{
private readonly IPreparationOptions<IParsingOptions> _preparationOptions;
private readonly bool _retainSourceText;
private readonly Dictionary<string, Environment.BindingName> _bindingNames = new(StringComparer.Ordinal);

public AstAnalyzer(IPreparationOptions<IParsingOptions> preparationOptions)
{
_preparationOptions = preparationOptions;
_retainSourceText = preparationOptions.ParsingOptions.RetainFunctionSourceText;
}

public void NodeVisitor(Node node, in OnNodeContext ctx)
Expand Down Expand Up @@ -113,7 +115,7 @@ public void NodeVisitor(Node node, in OnNodeContext ctx)
case NodeType.ArrowFunctionExpression:
case NodeType.FunctionDeclaration:
case NodeType.FunctionExpression:
node.UserData = JintFunctionDefinition.BuildState((IFunction) node, ctx.Input);
node.UserData = JintFunctionDefinition.BuildState((IFunction) node, _retainSourceText ? ctx.Input : null);
break;

case NodeType.Program:
Expand Down
8 changes: 6 additions & 2 deletions Jint/Engine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ private Engine(Options? options, Action<Engine, Options>? configure)
CallStack = new JintCallStack(Options.Constraints.MaxRecursionDepth >= 0);
_stackGuard = new StackGuard(this);

var defaultParserOptions = ScriptParsingOptions.Default.GetParserOptions(Options);
var scriptParsingDefaults = Options.RetainFunctionSourceText
? ScriptParsingOptions.RetainingDefault
: ScriptParsingOptions.Default;
var defaultParserOptions = scriptParsingDefaults.GetParserOptions(Options);
_defaultParser = new Parser(defaultParserOptions);
}

Expand Down Expand Up @@ -229,7 +232,8 @@ internal Options Options

public DebugHandler Debugger => _debugger ??= new DebugHandler(this, Options.Debugger.InitialStepMode);

internal ParserOptions DefaultModuleParserOptions => _defaultModuleParserOptions ??= ModuleParsingOptions.Default.GetParserOptions(Options);
internal ParserOptions DefaultModuleParserOptions => _defaultModuleParserOptions ??=
(Options.RetainFunctionSourceText ? ModuleParsingOptions.RetainingDefault : ModuleParsingOptions.Default).GetParserOptions(Options);

internal ParserOptions GetActiveParserOptions()
{
Expand Down
11 changes: 11 additions & 0 deletions Jint/Options.Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ public static Options Strict(this Options options, bool strict = true)
return options;
}

/// <summary>
/// Whether the engine's default parser retains function source text so that
/// <c>Function.prototype.toString()</c> returns the original source. Defaults to <see langword="false"/>
/// (returns a <c>function name() { [native code] }</c> placeholder and avoids keeping the source in memory).
/// </summary>
public static Options RetainFunctionSourceText(this Options options, bool retain = true)
{
options.RetainFunctionSourceText = retain;
return options;
}

/// <summary>
/// Selects the handling for script <code>debugger</code> statements.
/// </summary>
Expand Down
13 changes: 13 additions & 0 deletions Jint/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,19 @@ public class Options
/// </summary>
public bool Strict { get; set; }

/// <summary>
/// Whether the engine's default parser retains the full source text of parsed functions so that
/// <see cref="Native.Function.Function.ToString"><c>Function.prototype.toString()</c></see> can return
/// the original source. Defaults to <see langword="false"/>, in which case <c>toString()</c> returns a
/// <c>function name() { [native code] }</c> placeholder and the script source is not kept in memory.
/// </summary>
/// <remarks>
/// This only affects scripts/modules parsed by the engine itself (e.g. <see cref="Engine"/>'s <c>Execute(string)</c>).
/// When parsing via an explicit <see cref="ScriptParsingOptions"/>/<see cref="ModuleParsingOptions"/> or a
/// prepared script/module, the corresponding <see cref="IParsingOptions.RetainFunctionSourceText"/> setting applies.
/// </remarks>
public bool RetainFunctionSourceText { get; set; }

/// <summary>
/// The culture the engine runs on, defaults to current culture.
/// </summary>
Expand Down
34 changes: 32 additions & 2 deletions Jint/ParsingOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ public interface IParsingOptions
/// Defaults to <see langword="false"/>.
/// </summary>
bool Tolerant { get; init; }

/// <summary>
/// Gets or sets whether to retain the full source text of parsed functions so that
/// <see cref="Native.Function.Function.ToString"><c>Function.prototype.toString()</c></see>
/// can return the original source.
/// When <see langword="false"/> (the default), the source text is not kept and <c>toString()</c>
/// returns a <c>function name() { [native code] }</c> placeholder. This avoids retaining the entire
/// script source in memory, which can be significant for large and/or cached (prepared) scripts.
/// </summary>
bool RetainFunctionSourceText { get; init; }
}

public sealed record ScriptParsingOptions : IParsingOptions
Expand All @@ -43,11 +53,17 @@ public sealed record ScriptParsingOptions : IParsingOptions
AllowReturnOutsideFunction = true,
AllowTopLevelUsing = true,
OnRegExp = Engine.DefaultConvertRegExpHandler,
OnNode = Engine.DefaultNodeHandler,
// OnNode (source-text retention) is applied conditionally in ApplyTo based on RetainFunctionSourceText.
};

public static readonly ScriptParsingOptions Default = new();

/// <summary>
/// A <see cref="Default"/> variant that retains function source text. Used by the engine when
/// <see cref="Options.RetainFunctionSourceText"/> is enabled to build its default parser.
/// </summary>
internal static readonly ScriptParsingOptions RetainingDefault = new() { RetainFunctionSourceText = true };

/// <summary>
/// Gets or sets whether to allow return statements at the top level.
/// Defaults to <see langword="true"/>.
Expand All @@ -63,6 +79,9 @@ public sealed record ScriptParsingOptions : IParsingOptions
/// <inheritdoc/>
public bool Tolerant { get; init; } = _defaultParserOptions.Tolerant;

/// <inheritdoc/>
public bool RetainFunctionSourceText { get; init; }

/// <summary>
/// Gets or sets the source location offset to apply when parsing.
/// This allows mapping error locations back to the original source file
Expand All @@ -76,6 +95,7 @@ internal ParserOptions ApplyTo(ParserOptions baseOptions, bool fallbackCompileRe
{
AllowReturnOutsideFunction = AllowReturnOutsideFunction,
OnRegExp = GetOnRegExpHandler(fallbackCompileRegex, fallbackRegexTimeout),
OnNode = RetainFunctionSourceText ? Engine.DefaultNodeHandler : null,
Tolerant = Tolerant,
};

Expand Down Expand Up @@ -111,11 +131,17 @@ public sealed record class ModuleParsingOptions : IParsingOptions
private static readonly ParserOptions _defaultParserOptions = Engine.BaseParserOptions with
{
OnRegExp = Engine.DefaultConvertRegExpHandler,
OnNode = Engine.DefaultNodeHandler,
// OnNode (source-text retention) is applied conditionally in ApplyTo based on RetainFunctionSourceText.
};

public static readonly ModuleParsingOptions Default = new();

/// <summary>
/// A <see cref="Default"/> variant that retains function source text. Used by the engine when
/// <see cref="Options.RetainFunctionSourceText"/> is enabled to build its default module parser.
/// </summary>
internal static readonly ModuleParsingOptions RetainingDefault = new() { RetainFunctionSourceText = true };

/// <inheritdoc/>
public bool? CompileRegex { get; init; }

Expand All @@ -125,9 +151,13 @@ public sealed record class ModuleParsingOptions : IParsingOptions
/// <inheritdoc/>
public bool Tolerant { get; init; } = _defaultParserOptions.Tolerant;

/// <inheritdoc/>
public bool RetainFunctionSourceText { get; init; }

internal ParserOptions ApplyTo(ParserOptions baseOptions, bool fallbackCompileRegex, TimeSpan fallbackRegexTimeout) => baseOptions with
{
OnRegExp = GetOnRegExpHandler(fallbackCompileRegex, fallbackRegexTimeout),
OnNode = RetainFunctionSourceText ? Engine.DefaultNodeHandler : null,
Tolerant = Tolerant,
};

Expand Down
2 changes: 1 addition & 1 deletion Jint/Test262Object.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public static ObjectInstance Install(Engine engine)

var script = Engine.PrepareScript(args.At(0).AsString(), options: new ScriptPreparationOptions
{
ParsingOptions = ScriptParsingOptions.Default with { Tolerant = false },
ParsingOptions = ScriptParsingOptions.Default with { Tolerant = false, RetainFunctionSourceText = true },
});

return engine.Evaluate(script);
Expand Down
Loading