Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/embedding.md
Original file line number Diff line number Diff line change
Expand Up @@ -876,7 +876,7 @@ Raises `TGocciaInstructionLimitError` when the limit is reached. A value of zero

In bytecode mode the VM uses a trampoline: bytecode-to-bytecode calls are dispatched iteratively via an explicit frame stack, so the Pascal call stack stays flat regardless of JS call depth. The interpreter mode uses Pascal recursion and relies on the depth check to prevent overflow.

Every call of a function written in script counts as one nested call, however it is made: a plain or method call, `new` and `super()`, a getter or setter, a Proxy trap, a `toString`, `valueOf` or `Symbol.toPrimitive` method, an iterator's `next`, or a callback a built-in calls. A built-in function itself adds no frame, so `[n].map(f)` and the `f` it calls count as one. The interpreter does not count `super()` calls, accessors, Proxy traps, `ToPrimitive` methods or iterator methods. Most of those calls are native re-entries into the bytecode VM: the bytecode loop runs on a fresh Pascal stack frame instead of the trampoline, and each one costs a few kilobytes of native stack. How many fit depends on the thread's stack size and on the build. Once less than `NATIVE_STACK_RESERVE` (256 KiB, or a quarter of a stack smaller than 1 MiB) of the thread's stack is left, the VM throws the same `RangeError` instead of entering again, so no `--max-stack` value, including 0, lets these re-entries overflow the native stack. The check runs only where the bytecode loop is entered: a chain of native calls, such as Proxies forwarding to their targets or bound functions calling theirs, is not checked against the stack in between, and the delegation caps below bound only some of those chains. The bounds come from the operating system (`/proc/self/maps` and `RLIMIT_STACK` on Linux, `pthread_get_stackaddr_np` on Darwin, `GetCurrentThreadStackLimits` on Windows) and from the RTL's `StackBottom` where that is higher. On Linux the main thread's stack is taken to grow down to `RLIMIT_STACK` even where its mapping is not marked `[stack]`, as under Valgrind. The first `NATIVE_REENTRY_UNCHECKED_DEPTH` (8) nested re-entries are not checked, so a program that does not nest them never looks the bounds up. Where they cannot be found, at most `MAX_NATIVE_REENTRY_DEPTH` (512) re-entries are live at once. A production x86-64 build on an 8 MiB stack, the Linux and macOS main-thread default and the test runner's worker size, fits 2 200 nested constructor, async-function, generator-`next()` or getter calls. A setter, a Proxy trap, a `toString` method or a callback from `sort`, `JSON.stringify` or `Reflect.construct` costs more, and recursion through one stops at between about 1 600 and 2 100 calls. Property lookups walk a chain of ordinary objects in a loop, with no length limit. Constructing a class walks the classes above it whose constructors are default ones in a loop as well, with no length limit. A Proxy in the chain, a Proxy's target, a Proxy's trap and a handler that is itself a Proxy are entered by native calls, for every internal method a Proxy forwards (`[[GetPrototypeOf]]`, `[[SetPrototypeOf]]`, `[[IsExtensible]]`, `[[PreventExtensions]]`, `[[GetOwnProperty]]`, `[[DefineOwnProperty]]`, `[[HasProperty]]`, `[[Get]]`, `[[Set]]`, `[[Delete]]`, `[[OwnPropertyKeys]]`, `[[Call]]` and `[[Construct]]`); at most `MAX_PROPERTY_DELEGATION_DEPTH` (2 000) of them are live at once, and past that the operation throws the same `RangeError`. A bound function between two Proxies is not counted, so a chain that alternates Proxies with bound functions, or a trap wrapped in several bound functions, can still exhaust the native stack. `typeof` and the callable check step through directly nested Proxies in a loop, since they cannot throw. Arrays, functions, classes and class instances in the chain are entered by native calls too, counted against `MAX_OBJECT_DELEGATION_DEPTH` (3 000). A prototype cycle closed through a Proxy, which ES2026 §10.1.2.1 OrdinarySetPrototypeOf accepts, ends this way. `instanceof` and `Object.prototype.isPrototypeOf` follow `[[GetPrototypeOf]]` in a loop instead and throw after `MAX_PROXY_PROTOTYPE_STEPS` (100 000) steps through a Proxy.
Every call of a function written in script counts as one nested call, however it is made: a plain or method call, `new` and `super()`, a getter or setter, a Proxy trap, a `toString`, `valueOf` or `Symbol.toPrimitive` method, an iterator's `next`, or a callback a built-in calls. A built-in function itself adds no frame, so `[n].map(f)` and the `f` it calls count as one. The interpreter does not count `super()` calls, accessors, Proxy traps, `ToPrimitive` methods or iterator methods. Most of those calls are native re-entries into the bytecode VM: the bytecode loop runs on a fresh Pascal stack frame instead of the trampoline, and each one costs a few kilobytes of native stack. How many fit depends on the thread's stack size and on the build. Once less than `NATIVE_STACK_RESERVE` (256 KiB, or a quarter of a stack smaller than 1 MiB) of the thread's stack is left, the VM throws the same `RangeError` instead of entering again, so no `--max-stack` value, including 0, lets these re-entries overflow the native stack. The same check runs in a chain of native calls that does not enter the bytecode loop, such as Proxies forwarding to their targets, once more than `NATIVE_REENTRY_UNCHECKED_DEPTH` of the counted delegations below are live. The bounds come from the operating system (`/proc/self/maps` and `RLIMIT_STACK` on Linux, `pthread_get_stackaddr_np` on Darwin, `GetCurrentThreadStackLimits` on Windows) and from the RTL's `StackBottom` where that is higher. On Linux the main thread's stack is taken to grow down to `RLIMIT_STACK` even where its mapping is not marked `[stack]`, as under Valgrind. The first `NATIVE_REENTRY_UNCHECKED_DEPTH` (8) nested re-entries are not checked, so a program that does not nest them never looks the bounds up. Where they cannot be found, at most `MAX_NATIVE_REENTRY_DEPTH` (512) re-entries are live at once. A production x86-64 build on an 8 MiB stack, the Linux and macOS main-thread default and the test runner's worker size, fits 2 200 nested constructor, async-function, generator-`next()` or getter calls. A setter, a Proxy trap, a `toString` method or a callback from `sort`, `JSON.stringify` or `Reflect.construct` costs more, and recursion through one stops at between about 1 600 and 2 100 calls. Property lookups walk a chain of ordinary objects in a loop, with no length limit. Constructing a class walks the classes above it whose constructors are default ones in a loop as well, with no length limit. A Proxy in the chain, a Proxy's target, a Proxy's trap and a handler that is itself a Proxy are entered by native calls, for every internal method a Proxy forwards (`[[GetPrototypeOf]]`, `[[SetPrototypeOf]]`, `[[IsExtensible]]`, `[[PreventExtensions]]`, `[[GetOwnProperty]]`, `[[DefineOwnProperty]]`, `[[HasProperty]]`, `[[Get]]`, `[[Set]]`, `[[Delete]]`, `[[OwnPropertyKeys]]`, `[[Call]]` and `[[Construct]]`); at most `MAX_PROPERTY_DELEGATION_DEPTH` (2 000) of them are live at once, and past that the operation throws the same `RangeError`. A Proxy's `[[Call]]` or `[[Construct]]` forward to a bound function counts too, and so does its `[[Construct]]` forward to a class, which a class that extends a Proxy reaches from its implicit constructor; so a chain that alternates Proxies with bound functions, or classes that each extend a Proxy of the previous one, end in the same `RangeError`. A chain of bound functions alone is followed in a loop for `[[Call]]` and `[[Construct]]` (ES2026 §10.4.1.1 and §10.4.1.2), with no length limit, while `instanceof` against one counts each bound function, because each step reads the next target's `@@hasInstance`. `typeof`, the callable check and the constructor check step through directly nested Proxies in a loop, since they cannot throw, and the constructor check steps through bound functions between them as well. Arrays, functions, classes and class instances in the chain are entered by native calls too, counted against `MAX_OBJECT_DELEGATION_DEPTH` (3 000). A prototype cycle closed through a Proxy, which ES2026 §10.1.2.1 OrdinarySetPrototypeOf accepts, ends this way. `instanceof` and `Object.prototype.isPrototypeOf` follow `[[GetPrototypeOf]]` in a loop instead and throw after `MAX_PROXY_PROTOTYPE_STEPS` (100 000) steps through a Proxy.

```pascal
uses
Expand Down
51 changes: 51 additions & 0 deletions scripts/depth-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,24 @@ const bindChain = (depth, fn) => {
}
return bound;
};
// bind names each level "bound " + its target's name, so these chains hide
// the name to keep their memory linear in the depth.
const namelessBindChain = (depth, fn) => {
let bound = fn;
for (const _ of Array.from({ length: depth })) {
bound = bound.bind(null);
Object.defineProperty(bound, "name", { value: "" });
}
return bound;
};
const proxyBindChain = (depth, fn) => {
const handler = { get: (target, key, receiver) => (key === "name" ? undefined : Reflect.get(target, key, receiver)) };
let f = fn;
for (const _ of Array.from({ length: depth })) {
f = Function.prototype.bind.call(new Proxy(f, handler), null);
}
return f;
};
const describeError = (error) => {
if (error !== null && typeof error === "object") {
const ctor = error.constructor;
Expand Down Expand Up @@ -263,6 +281,39 @@ const CATALOG: Probe[] = [
summary: "Call a function bound DEPTH times",
body: (d) => `report(() => bindChain(${d}, () => 1)());\n`,
},
{
id: "bind-chain-construct",
growth: "linear",
summary: "new on a class bound DEPTH times",
body: (d) => `report(() => typeof new (namelessBindChain(${d}, class {}))());\n`,
},
{
id: "bind-chain-instanceof",
growth: "linear",
summary: "instanceof against a class bound DEPTH times (each level reads @@hasInstance)",
body: (d) => `report(() => { const C = class {}; return new C() instanceof namelessBindChain(${d}, C); });\n`,
},
{
id: "proxy-bind-alternating-call",
growth: "linear",
summary: "Call a chain that alternates a Proxy and a bound function DEPTH times (#1497)",
body: (d) =>
`report(() => proxyBindChain(${d}, () => 1)());\n`,
},
{
id: "proxy-bind-alternating-construct",
growth: "linear",
summary: "new on a chain that alternates a Proxy and a bound function DEPTH times (#1497)",
body: (d) =>
`report(() => typeof new (proxyBindChain(${d}, class {}))());\n`,
},
{
id: "class-extends-proxy-chain",
growth: "linear",
summary: "Construct the most derived of DEPTH classes that each extend a Proxy of the previous one (#1497)",
body: (d) =>
`report(() => { let C = class {}; for (const _ of Array.from({ length: ${d} })) { const B = new Proxy(C, {}); C = class extends B {}; } return typeof new C(); });\n`,
},
{
id: "class-extends-chain",
growth: "linear",
Expand Down
16 changes: 9 additions & 7 deletions source/units/Goccia.Builtins.TestingLibrary.pas
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ TGocciaTestSuite = class(TGocciaRegisteredEntry)
const AIsSkipped: Boolean = False; const AIsFocused: Boolean = False);
destructor Destroy; override;
procedure AddEntry(const AEntry: TGocciaRegisteredEntry);
procedure AddHook(const ACallback: TGocciaFunctionBase; const APhase: TGocciaTestHookPhase);
procedure AddHook(const ACallback: TGocciaValue; const APhase: TGocciaTestHookPhase);
procedure ClearRegisteredContent;
function DisplayName: string; override;
function GetFullName: string; override;
Expand Down Expand Up @@ -1138,7 +1138,7 @@ procedure TGocciaTestSuite.AddEntry(const AEntry: TGocciaRegisteredEntry);
Entries.Add(AEntry);
end;

procedure TGocciaTestSuite.AddHook(const ACallback: TGocciaFunctionBase;
procedure TGocciaTestSuite.AddHook(const ACallback: TGocciaValue;
const APhase: TGocciaTestHookPhase);
begin
AddTempRootIfNeeded(ACallback);
Expand Down Expand Up @@ -3721,11 +3721,11 @@ procedure TGocciaTestAssertions.RegisterHook(
begin
TGocciaArgumentValidator.RequireExactly(AArgs, 1, AHookName, ThrowError);

if not (AArgs.GetElement(0) is TGocciaFunctionBase) then
// Any callable, a callable Proxy included: RunCallbacks dispatches it.
if not AArgs.GetElement(0).IsCallable then
Goccia.Values.ErrorHelper.ThrowTypeError(Format(SErrorFunctionExpectsFunctionArg, [AHookName]), SSuggestTestUsage);

GetCurrentRegistrationSuite.AddHook(TGocciaFunctionBase(AArgs.GetElement(0)),
APhase);
GetCurrentRegistrationSuite.AddHook(AArgs.GetElement(0), APhase);
end;

function TGocciaTestAssertions.GetEachRowArguments(
Expand Down Expand Up @@ -4642,7 +4642,8 @@ procedure TGocciaTestAssertions.RunCallbacks(const ACallbacks: TGocciaArgumentsC
try
try
HookFailed := False;
CallbackResult := TGocciaFunctionBase(Callback).Call(EmptyArgs, TGocciaUndefinedLiteralValue.UndefinedValue);
CallbackResult := DispatchCall(Callback, EmptyArgs,
TGocciaUndefinedLiteralValue.UndefinedValue);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (TGarbageCollector.Instance <> nil) then
TGarbageCollector.Instance.AddTempRoot(CallbackResult);
Expand Down Expand Up @@ -5233,7 +5234,8 @@ function TGocciaTestAssertions.OnTestFinished(const AArgs: TGocciaArgumentsColle
begin
TGocciaArgumentValidator.RequireExactly(AArgs, 1, 'onTestFinished', ThrowError);

if not (AArgs.GetElement(0) is TGocciaFunctionBase) then
// Any callable, a callable Proxy included: RunCallbacks dispatches it.
if not AArgs.GetElement(0).IsCallable then
Goccia.Values.ErrorHelper.ThrowTypeError(SErrorOnTestFinishedExpectsFunction, SSuggestTestUsage);

AddTempRootIfNeeded(AArgs.GetElement(0));
Expand Down
Loading
Loading