From 241dc76ca5a3bc7c5dcb10933a9a52bbfff06994 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 25 Sep 2026 15:50:42 -0500 Subject: [PATCH] fix(codemode): bound replacement strings, argument counts, built-in recursion depth, thenable chains, and rejection diagnostics --- packages/codemode/interpreter-support.md | 25 ++- packages/codemode/src/interpreter/globals.ts | 14 +- .../codemode/src/interpreter/interpreter.ts | 23 ++- packages/codemode/src/interpreter/limits.ts | 12 ++ packages/codemode/src/interpreter/model.ts | 13 +- packages/codemode/src/interpreter/promises.ts | 39 ++++- packages/codemode/src/stdlib/array.ts | 1 + packages/codemode/src/stdlib/bytes.ts | 14 +- packages/codemode/src/stdlib/string.ts | 48 +++++- packages/codemode/src/stdlib/url.ts | 18 +- packages/codemode/src/stdlib/web.ts | 2 + packages/codemode/test/parity.test.ts | 156 +++++++++++++++++- 12 files changed, 326 insertions(+), 39 deletions(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 22ae85fcf12..c85bf0feb25 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -34,10 +34,20 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 shadowable by program declarations like other globals. - [x] Cooperative timeout, an optional total tool-call limit, output bounding, and unrestricted tool-call concurrency. - [x] The timeout fires between interpreter steps, so one built-in is bounded in what it may build: strings up to - 2^24 characters (`repeat`, `pad*`, `concat`, `join`, `+`, template literals, `JSON.stringify`), arrays up to - 10,000,000 elements (`Array(n)`, `length =`, `Array.from`, `split`, `matchAll`, `concat`, `flat`; below the JS - maximum of 2^32 - 1), and 10,000 pending promises at once. Exceeding one throws a `RangeError`. A single regular - expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook. + 2^24 characters (`repeat`, `pad*`, `concat`, `join`, `+`, template literals, `JSON.stringify`, `replace` and + `replaceAll` with a string replacement (checked before the host builds the result), `encodeURI*`, `btoa`, + `Uint8Array` `toString`/`toBase64`/`toHex`, `URLSearchParams.toString`), arrays up to 10,000,000 elements + (`Array(n)`, `length =`, `Array.from`, spread, rest, `split`, `matchAll`, `concat`, `flat`; below the JS maximum + of 2^32 - 1), 250,000 arguments to one call (spread arguments, `apply`, bound arguments), and 10,000 pending + promises at once. Exceeding one throws a `RangeError`. A replacement whose quick bound is too big is measured + first: the check is exact for a global replacement without `$`, and otherwise charges each `$` token the longest + group of its match (a whole subject for `` $` `` and `$'`), so a result near the cap may still be rejected. + `encodeURI*` and `Uint8Array.toString` are checked after the host builds the string (at most nine times a capped + input). Unhandled rejections from un-awaited promises are reported individually up to 100, each message cut at + 4,096 characters, with one summary warning counting the rest that were never handled. +- [ ] A single regular expression match can still run long on a pathological pattern (`/a*a*a*a*b/` on a 1 KB + subject): the host regex engine has no interrupt hook and no subject-length cap helps beyond quadratic patterns, + so this needs a step-counted regex engine of our own. - [x] A trailing comma after a rest parameter is a syntax error, with or without `"use strict"`. - [x] A program that begins with `"use strict"` rejects `yield` as an identifier and duplicate parameter names at parse time. Without it, `yield` is an ordinary binding. @@ -124,8 +134,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 - [x] Closures, recursion, default parameters, rest parameters, and destructured parameters. - [x] A call depth limit of 10000: deeper nesting throws a catchable `RangeError: Maximum call stack size exceeded` at the overflowing call instead of running until the timeout. Callbacks invoked by built-ins count below the - call that invoked the built-in, and a resumed `await` starts from depth 0 as in JS, so long async chains such - as recursive pagination are unaffected. + call that invoked the built-in, and built-ins invoking one another count toward the same limit, so a cycle + through built-ins alone (`String(a)` on a 100,000-deep nested array runs `toString` → `join` → `toString`) + bottoms out too. A resumed `await` starts from depth 0 as in JS, so long async chains such as recursive pagination are + unaffected. A thenable that keeps resolving with another thenable loops in constant memory until the timeout, as + in JS. - [x] Expression and block function bodies. - [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from` mapper APIs, with one shared acceptance rule everywhere including promise reactions. diff --git a/packages/codemode/src/interpreter/globals.ts b/packages/codemode/src/interpreter/globals.ts index f56ed2122f5..3999b9a7d49 100644 --- a/packages/codemode/src/interpreter/globals.ts +++ b/packages/codemode/src/interpreter/globals.ts @@ -21,7 +21,7 @@ import { errorGlobal } from "./errors.js" import { errorTypes } from "./intrinsics.js" import { constants, constructor, methods, native, receiver } from "./native.js" import { AsyncIteratorSymbol, IteratorSymbol, typeError } from "./model.js" -import { checkArrayLength } from "./limits.js" +import { checkArgumentCount } from "./limits.js" import { generatorGlobals } from "./generators.js" import { promiseGlobal } from "./promises.js" import type { Interpreter } from "./interpreter.js" @@ -45,7 +45,10 @@ const functionGlobal = (ctx: Interpreter) => { return native(ctx.builtins, { name: `bound ${coerceToString(get(fn, "name"))}`, length: Math.max(0, fn.length - bound.length), - call: (_, rest) => ctx.call(fn, args[0], [...bound, ...rest]), + call: (_, rest) => { + checkArgumentCount(bound.length + rest.length) + return ctx.call(fn, args[0], [...bound, ...rest]) + }, }) }, ], @@ -61,10 +64,13 @@ const functionGlobal = (ctx: Interpreter) => { // CreateListFromArrayLike: `apply` reads `length` and the indexed properties of any object. const listFromArrayLike = (value: Value): Array => { if (value === undefined || value === null) return [] - if (value instanceof Arr) return [...value.items] + if (value instanceof Arr) { + checkArgumentCount(value.items.length) + return [...value.items] + } if (!(value instanceof Obj)) throw typeError("Function.prototype.apply expects an array-like argument list.") const length = Math.max(0, coerceToInteger(get(value, "length"))) - checkArrayLength(length) + checkArgumentCount(length) return Array.from({ length }, (_, index) => get(value, String(index))) } diff --git a/packages/codemode/src/interpreter/interpreter.ts b/packages/codemode/src/interpreter/interpreter.ts index 7233377779a..cf784185a19 100644 --- a/packages/codemode/src/interpreter/interpreter.ts +++ b/packages/codemode/src/interpreter/interpreter.ts @@ -63,7 +63,7 @@ import { typeError, unsupportedSyntax, } from "./model.js" -import { checkStringLength } from "./limits.js" +import { checkArgumentCount, checkArrayLength, checkStringLength } from "./limits.js" import { locate, materialize } from "./errors.js" import { type Builtins, primitivePrototype } from "./intrinsics.js" import { globals } from "./globals.js" @@ -319,7 +319,7 @@ export class Interpreter { readonly logs: Array /** Template objects by site: a tag sees the same `strings` array every time its literal is evaluated, as in JS. */ readonly templates = new WeakMap() - private readonly root: Frame + readonly root: Frame constructor(options: { readonly tools: ToolRuntime @@ -1281,6 +1281,7 @@ class Frame { const next = yield* cursor.next done = next.done if (!done) rest.push(next.value) + checkArrayLength(rest.length) } yield* consume(element.argument, new Arr(self.ctx.builtins.Array, rest), element) return @@ -1728,15 +1729,19 @@ class Frame { } // Built-ins throw without a location, synchronously or inside their Effect; the call site supplies it. A built-in - // reached through `ctx.call` runs on the root frame, so the deeper of the frame and the enclosing site counts. + // reached through `ctx.call` runs on the root frame, so the deeper of the frame and the enclosing site counts. There + // it was invoked by another built-in, and those hops are counted too, so a cycle through built-ins alone + // (toString → join → toString) bottoms out without changing the depth of program calls. private native(body: () => Effect.Effect, node?: AstNode): Effect.Effect { - return Effect.flatMap(CallSite, (site) => - Effect.provideService( + return Effect.flatMap(CallSite, (site) => { + const natives = this === this.ctx.root ? site.natives + 1 : 0 + if (natives > MAX_CALL_DEPTH) throw rangeError("Maximum call stack size exceeded", node) + return Effect.provideService( Effect.catchDefect(Effect.suspend(body), (defect) => Effect.die(locate(defect, node))), CallSite, - { node, depth: Math.max(this.depth, site.depth) }, - ), - ) + { node, depth: Math.max(this.depth, site.depth), natives }, + ) + }) } private evaluateCallArguments( @@ -1754,6 +1759,7 @@ class Frame { const step = yield* cursor.next if (step.done) break args.push(step.value) + checkArgumentCount(args.length) } } else { args.push(yield* self.evaluateExpression(argNode)) @@ -2113,6 +2119,7 @@ class Frame { const step = yield* cursor.next if (step.done) break values.push(step.value) + checkArrayLength(values.length) } } else { values.push(yield* self.evaluateExpression(element)) diff --git a/packages/codemode/src/interpreter/limits.ts b/packages/codemode/src/interpreter/limits.ts index 18bada5f301..bd9b3796968 100644 --- a/packages/codemode/src/interpreter/limits.ts +++ b/packages/codemode/src/interpreter/limits.ts @@ -7,6 +7,8 @@ import { rangeError } from "./model.js" export const MAX_STRING_LENGTH = 1 << 24 /** Longest array a built-in may create or grow to. */ export const MAX_ARRAY_LENGTH = 10_000_000 +/** Most arguments one call may receive, including a bound function's bound arguments. */ +export const MAX_ARGUMENTS = 250_000 /** Most promises that may be pending at once. */ export const MAX_PENDING_PROMISES = 10_000 /** Deepest nesting a value may have when it crosses to or from the host. */ @@ -16,6 +18,16 @@ export const checkStringLength = (length: number): void => { if (length > MAX_STRING_LENGTH) throw rangeError("Invalid string length") } +/** A string the host already built, after checking its length; for outputs at most nine times a capped input. */ +export const boundedString = (value: string): string => { + checkStringLength(value.length) + return value +} + +export const checkArgumentCount = (count: number): void => { + if (count > MAX_ARGUMENTS) throw rangeError(`Too many arguments: a call may pass at most ${MAX_ARGUMENTS}.`) +} + export const checkArrayLength = (length: number): void => { if (length > MAX_ARRAY_LENGTH) throw rangeError("Invalid array length") } diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index e29d578cfa2..363da93f251 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -7,10 +7,15 @@ import type { ErrorObj, Value } from "./objects.js" /** Any parsed node; the interpreter narrows on `type` and reads `loc` for diagnostics. */ export type AstNode = Node -/** The program call a built-in is running under: where to locate failures born inside it, and how deep the stack is there. */ -export const CallSite = Context.Reference<{ readonly node?: AstNode; readonly depth: number }>("codemode/CallSite", { - defaultValue: () => ({ depth: 0 }), -}) +/** + * The program call a built-in is running under: where to locate failures born inside it, how deep the stack is there, + * and how many built-ins have invoked one another since program code last called one. + */ +export const CallSite = Context.Reference<{ + readonly node?: AstNode + readonly depth: number + readonly natives: number +}>("codemode/CallSite", { defaultValue: () => ({ depth: 0, natives: 0 }) }) export type Binding = { mutable: boolean diff --git a/packages/codemode/src/interpreter/promises.ts b/packages/codemode/src/interpreter/promises.ts index 4a82667e076..462a0d068b7 100644 --- a/packages/codemode/src/interpreter/promises.ts +++ b/packages/codemode/src/interpreter/promises.ts @@ -16,12 +16,20 @@ const capability = (ctx: Interpreter, name: string, settle: (value: Value) return undefined }) +// Unhandled rejections past the first hundred are only counted, and each reported message is cut, so a rejection loop +// cannot grow the report without bound. +const MAX_REJECTION_DIAGNOSTICS = 100 +const MAX_DIAGNOSTIC_LENGTH = 4096 + // Observation only controls rejection reporting; program completion interrupts all promise work. export class Pending { private readonly active = new Set() private readonly ids = new WeakMap() private readonly observed = new WeakSet() private readonly failures = new Map() + // An unreachable dropped promise can never be handled later, so a weak set is enough to keep the count exact. + private readonly dropped = new WeakSet() + private droppedCount = 0 private nextID = 0 constructor( @@ -60,11 +68,17 @@ export class Pending { this.ids.delete(promise) return } + if (this.failures.size >= MAX_REJECTION_DIAGNOSTICS) { + this.dropped.add(promise) + this.droppedCount += 1 + return + } const failure = normalizeError(Cause.squash(exit.cause)) - this.failures.set(id, { - ...failure, - message: `Unhandled rejection from an un-awaited promise: ${failure.message}`, - }) + const reason = failure.message.slice(0, MAX_DIAGNOSTIC_LENGTH) + // A JSC slice shares the memory of the string it cuts; slicing the short joined string copies it, so the long + // original message is freed. + const message = `Unhandled rejection from an un-awaited promise: ${reason}`.slice(0, MAX_DIAGNOSTIC_LENGTH) + this.failures.set(id, { ...failure, message }) }) return promise }) @@ -74,6 +88,7 @@ export class Pending { // Observation must be recorded when responsibility transfers, before the consumer fiber runs. markObserved(promise: PromiseObj): void { this.observed.add(promise) + if (this.dropped.delete(promise)) this.droppedCount -= 1 const id = this.ids.get(promise) this.ids.delete(promise) if (id !== undefined) this.failures.delete(id) @@ -88,7 +103,15 @@ export class Pending { } diagnostics(): Array { - return [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure) + const retained = [...this.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure) + if (this.droppedCount === 0) return retained + return [ + ...retained, + { + kind: "ExecutionFailure", + message: `Unhandled rejections from un-awaited promises not reported individually: ${this.droppedCount}.`, + }, + ] } // Re-check because a straggler can create promises before its interruption lands. @@ -116,6 +139,8 @@ export const resolvePromiseValue = ( const then = get(value, "then") if (typeofValue(then) !== "function") return Effect.succeed(value) + // The next thenable resolves through a tail flatMap, not a nested yield*, so a chain that never ends runs in + // constant memory, as in JS. return Effect.gen(function* () { // Promise resolution invokes a thenable's method in a later job. yield* Effect.yieldNow @@ -127,8 +152,8 @@ export const resolvePromiseValue = ( if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause) Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause))) } - return yield* resolvePromiseValue(ctx, yield* Deferred.await(deferred), own) - }) + return yield* Deferred.await(deferred) + }).pipe(Effect.flatMap((next) => resolvePromiseValue(ctx, next, own))) } export const resolvePromise = (ctx: Interpreter, value: Value): Effect.Effect => { diff --git a/packages/codemode/src/stdlib/array.ts b/packages/codemode/src/stdlib/array.ts index a2f9dba0200..3a78b2adff0 100644 --- a/packages/codemode/src/stdlib/array.ts +++ b/packages/codemode/src/stdlib/array.ts @@ -64,6 +64,7 @@ const arrayFrom = (ctx: Interpreter, args: Array): Effect.Effect(ctx: Interpreter) => { return joined }, ], - ["toString", 0, (thisValue) => self(thisValue, "toString").bytes.join(",")], + ["toString", 0, (thisValue) => boundedString(self(thisValue, "toString").bytes.join(","))], ["toBase64", 0, (thisValue) => self(thisValue, "toBase64").bytes.toBase64()], - ["toHex", 0, (thisValue) => self(thisValue, "toHex").bytes.toHex()], + [ + "toHex", + 0, + (thisValue) => { + const bytes = self(thisValue, "toHex").bytes + checkStringLength(bytes.length * 2) + return bytes.toHex() + }, + ], ["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").bytes.keys())], ["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").bytes.values())], [ diff --git a/packages/codemode/src/stdlib/string.ts b/packages/codemode/src/stdlib/string.ts index d9cc9044973..e2d6521feb0 100644 --- a/packages/codemode/src/stdlib/string.ts +++ b/packages/codemode/src/stdlib/string.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { constructor, fn, type Impl, type Method, methods } from "../interpreter/native.js" -import { checkArrayLength, checkStringLength } from "../interpreter/limits.js" +import { checkArrayLength, checkStringLength, MAX_STRING_LENGTH } from "../interpreter/limits.js" import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js" import { define, @@ -36,6 +36,45 @@ const requireDataArgument = (name: string, index: number, arg: Value): Value => return arg } +// The host builds a replaced string in one synchronous step, so bound its length first: the unmatched text plus, per +// match, the replacement, a whole subject per `` $` `` or `$'`, and the match's longest group per `$` token. Groups lie +// inside their match, so they sum to at most the subject, unless a positive lookaround captures past it. Matches are +// only measured when the bound for "every position matches" is too big; the measured bound is exact without `$`. +const checkReplacementLength = (subject: string, search: RegExp | string, replacement: string, all: boolean): void => { + const tokens = (token: string) => countOccurrences(replacement, token) + const perMatch = replacement.length + (tokens("$`") + tokens("$'")) * subject.length + const bound = (matches: { count: number; matched: number; reach: number }) => + subject.length - matches.matched + matches.count * perMatch + tokens("$") * matches.reach + const lookaround = typeof search !== "string" && /\(\? ({ + count: total.count + 1, + matched: total.matched + match[0].length, + reach: total.reach + match.reduce((longest, group) => Math.max(longest, group?.length ?? 0), 0), + }), + { count: 0, matched: 0, reach: 0 }, + ) + checkStringLength(bound(matches)) +} + +const countOccurrences = (subject: string, needle: string): number => { + if (needle === "") return subject.length + 1 + let count = 0 + for (let index = subject.indexOf(needle); index !== -1; index = subject.indexOf(needle, index + needle.length)) { + count += 1 + } + return count +} + const replaceAllNeedsGlobal = (pattern: RegExp) => { if (!pattern.global) { throw typeError( @@ -208,10 +247,13 @@ export const stringGlobal = (ctx: Interpreter) => { const regex = pattern.regex const text = str(name, primitives, 1) if (name === "replaceAll") replaceAllNeedsGlobal(regex) + checkReplacementLength(value, regex, text, regex.global) return name === "replace" ? value.replace(regex, text) : value.replaceAll(regex, text) } - if (name === "replace") return value.replace(str(name, primitives, 0), str(name, primitives, 1)) - return value.replaceAll(str(name, primitives, 0), str(name, primitives, 1)) + const needle = str(name, primitives, 0) + const text = str(name, primitives, 1) + checkReplacementLength(value, needle, text, name === "replaceAll") + return name === "replace" ? value.replace(needle, text) : value.replaceAll(needle, text) }, ) }) diff --git a/packages/codemode/src/stdlib/url.ts b/packages/codemode/src/stdlib/url.ts index 666f692045c..c379eeb86f1 100644 --- a/packages/codemode/src/stdlib/url.ts +++ b/packages/codemode/src/stdlib/url.ts @@ -1,4 +1,5 @@ import { Effect } from "effect" +import { boundedString, checkStringLength } from "../interpreter/limits.js" import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js" import { IteratorSymbol, PendingThrow, typeError, uriError } from "../interpreter/model.js" import { @@ -46,9 +47,10 @@ export const uriGlobal = (ctx: Interpreter, name: UriFunction) => fn(ctx.builtins, name, 1, (_, args) => { const value = coerceToString(args[0]) try { - return uriFunctions[name](value) + return boundedString(uriFunctions[name](value)) } catch (error) { - throw uriError(`${name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`) + if (!(error instanceof URIError)) throw error + throw uriError(`${name} received malformed URI data: ${error.message}`) } }) @@ -272,7 +274,17 @@ export const urlSearchParamsGlobal = (ctx: Interpreter) => { ["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").params.keys())], ["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").params.values())], ["entries", 0, (thisValue) => hostIterator(builtins, self(thisValue, "entries").iterator(builtins))], - ["toString", 0, (thisValue) => self(thisValue, "toString").params.toString()], + [ + "toString", + 0, + (thisValue) => { + const params = self(thisValue, "toString").params + // Every character serializes to at least one, plus `=` per pair and `&` between pairs, so this lower bound + // rejects before the host builds anything. + checkStringLength([...params].reduce((length, [key, value]) => length + key.length + value.length + 2, -1)) + return boundedString(params.toString()) + }, + ], [ "forEach", 1, diff --git a/packages/codemode/src/stdlib/web.ts b/packages/codemode/src/stdlib/web.ts index a5a78bfaf50..c8b2ea66a41 100644 --- a/packages/codemode/src/stdlib/web.ts +++ b/packages/codemode/src/stdlib/web.ts @@ -1,4 +1,5 @@ import { fn, methods } from "../interpreter/native.js" +import { checkStringLength } from "../interpreter/limits.js" import { typeError } from "../interpreter/model.js" import { define, @@ -32,6 +33,7 @@ export const base64Global = (ctx: Interpreter, name: "atob" | "btoa") => fn(ctx.builtins, name, 1, (_, args) => { if (args.length === 0) throw typeError(`${name} requires 1 argument (a string)`) const input = coerceToString(args[0]) + if (name === "btoa") checkStringLength(Math.ceil(input.length / 3) * 4) try { return name === "atob" ? atob(input) : btoa(input) } catch { diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 12ca6b2465f..fc96692533c 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -1560,7 +1560,7 @@ describe("this, arguments, and Function.prototype.call/apply/bind", () => { "Function.prototype.call called on incompatible receiver", ) expect((await error(`(() => 1).apply(null, 5)`)).message).toContain("expects an array-like argument list") - expect((await error(`(() => 1).apply(null, { length: 1e9 })`)).message).toContain("Invalid array length") + expect((await error(`(() => 1).apply(null, { length: 1e9 })`)).message).toContain("Too many arguments") expect( await value( `function f() { return arguments.length } return [f.apply(null, { length: -5 }), f.apply(null, { length: "2" })]`, @@ -2060,3 +2060,157 @@ describe("small language leftovers", () => { expect((await error(`function* g() {} Object.getPrototypeOf(g)()`)).message).toContain("not a function") }) }) + +describe("confinement caps", () => { + test("string replacement results are bounded before the host builds them", async () => { + expect((await error(`"x".repeat(2 ** 16).replaceAll("x", "x".repeat(2 ** 9))`)).message).toContain( + "Invalid string length", + ) + expect((await error(`"x".repeat(2 ** 20).replace(/x/g, "yyyyyyyyyyyyyyyyyyyyyyyyy")`)).message).toContain( + "Invalid string length", + ) + // Every prefix token can repeat the subject once per match, and so can a capture inside a lookahead. + expect((await error('"x".repeat(2600).replace(/x/g, "$`".repeat(100))')).message).toContain("Invalid string length") + expect((await error('"x".repeat(20000).replace(/(?=(x*))/g, "$1")')).message).toContain("Invalid string length") + expect( + (await error(`const p = new URLSearchParams(); const s = "x".repeat(2 ** 24); p.append(s, s); p.toString()`)) + .message, + ).toContain("Invalid string length") + // Counting empty matches must step over surrogate pairs under the u flag. + expect( + await value( + `return ["😀".repeat(10).replace(/(?:)/gu, "x".repeat(2 ** 20)).length, "1234567".replace(/(\\d)(?=(\\d{3})+$)/g, "$1,")]`, + ), + ).toEqual([11534356, "1,234,567"]) + expect( + await value(` + return [ + "x".repeat(2 ** 23).replaceAll("x", "y").length, + "ab".repeat(2 ** 21).replace(/a/g, "$&").length, + "abc".replaceAll("", "-"), + "aaa".replace(/a/g, (m) => m + m), + "hello world ".repeat(2000).replace(/(\\w+)(?=\\s)/g, "[$1]").length, + "x".repeat(100).replaceAll("x", "y".repeat(167772)).length, + "a ".repeat(3e6).replace(/ +/g, " ").length, + ] + `), + ).toEqual([8388608, 4194304, "-a-b-c-", "aaaaaa", 32000, 16777200, 6000000]) + expect((await error(`encodeURIComponent("\\u{1F600}".repeat(2 ** 22))`)).message).toContain("Invalid string length") + expect((await error(`btoa("x".repeat(2 ** 24))`)).message).toContain("Invalid string length") + expect((await error(`new Uint8Array(9e6).toHex()`)).message).toContain("Invalid string length") + expect((await error(`new Uint8Array(9e6).toString()`)).message).toContain("Invalid string length") + expect( + (await error(`const p = new URLSearchParams(); p.append("\\u20ac".repeat(2 ** 21), ""); p.toString()`)).message, + ).toContain("Invalid string length") + expect((await error(`decodeURIComponent("%")`)).message).toContain("malformed URI") + }) + + test("recursion through built-ins alone hits the call depth limit", async () => { + expect( + await value(` + let a = [] + for (let i = 0; i < 100000; i++) a = [a] + let shallow = [] + for (let i = 0; i < 3000; i++) shallow = [shallow] + const names = [] + try { String(a) } catch (e) { names.push(e.name) } + try { \`\${a}\` } catch (e) { names.push(e.name) } + const b = [1] + b.join = b.toString + try { String(b) } catch (e) { names.push(e.name) } + return [names, String(shallow).length, [[[1]]].map((x) => String(x))] + `), + ).toEqual([["RangeError", "RangeError", "RangeError"], 0, ["1"]]) + // A resumed await starts from depth 0 even when each step recurses through a bound callback of a built-in. + expect( + await value(` + let n = 0 + let failure = null + async function step() { + await null + n++ + if (n < 12000) [0].forEach((() => { step().catch((e) => { failure = e.name }) }).bind(null)) + } + await step() + while (n < 12000 && failure === null) await null + return [n, failure] + `), + ).toEqual([12000, null]) + }) + + test("argument and spread counts are capped", async () => { + expect((await error(`Math.max(...Array(300000).fill(1))`)).message).toContain("Too many arguments") + expect((await error(`Math.max.apply(null, { length: 1e9 })`)).message).toContain("Too many arguments") + expect( + (await error(`function f() { return arguments.length } const a = Array(2e5).fill(0); f(...a, ...a)`)).message, + ).toContain("Too many arguments") + expect((await error(`function f() {} f.apply(null, Array(300000).fill(0))`)).message).toContain( + "Too many arguments", + ) + expect( + ( + await error( + `const a = Array(2e5).fill(0); const f = ((...xs) => xs.length).bind(null, ...a).bind(null, ...a); f()`, + ) + ).message, + ).toContain("Too many arguments") + expect((await error(`const a = Array(6e6).fill(0); [...a, ...a]`)).message).toContain("Invalid array length") + expect(await value(`return Math.max(...Array(200000).fill(7))`)).toBe(7) + }) + + test("a thenable chain resolves iteratively and a self-resolving promise is a chaining cycle", async () => { + expect( + await value( + `const mk = (n) => n === 0 ? "done" : { then(res) { res(mk(n - 1)) } }; return await Promise.resolve(mk(5000))`, + ), + ).toBe("done") + expect( + (await error(`let p; p = new Promise((r) => Promise.resolve().then(() => r(p))); await p`)).message, + ).toContain("Chaining cycle") + // The cycle check still applies after a thenable step hands back the promise being resolved. + expect( + (await error(`let p; p = new Promise((r) => Promise.resolve().then(() => r({ then(r2) { r2(p) } }))); await p`)) + .message, + ).toContain("Chaining cycle") + }) + + test("a thenable that resolves with itself runs in constant memory until the timeout", async () => { + Bun.gc(true) + const before = process.memoryUsage().heapUsed + let peak = before + const sample = setInterval(() => (peak = Math.max(peak, process.memoryUsage().heapUsed)), 50) + const result = await Effect.runPromise( + CodeMode.execute({ + code: `const t = { then(res) { res(t) } }; await Promise.resolve(t)`, + tools: {}, + limits: { timeoutMs: 1000 }, + }), + ) + clearInterval(sample) + expect(result.ok ? undefined : result.error.kind).toBe("TimeoutExceeded") + expect(peak - before).toBeLessThan(50_000_000) + }) + + test("unhandled rejection diagnostics are capped with a summary", async () => { + const result = await run(`for (let i = 0; i < 500; i++) Promise.reject(new Error("x".repeat(10000))); return 1`) + if (!result.ok) throw new Error(result.error.message) + expect(result.warnings?.length).toBe(101) + expect(result.warnings?.[0]?.message.length).toBeLessThanOrEqual(4096) + expect(result.warnings?.at(-1)?.message).toContain("not reported individually: 400") + const handled = await run( + `const ps = []; for (let i = 0; i < 200; i++) ps.push(Promise.reject(new Error("x"))); await Promise.allSettled(ps); return 1`, + ) + expect(handled.ok && handled.warnings).toBeUndefined() + }) + + test("cut rejection messages do not keep the full message alive", async () => { + Bun.gc(true) + const before = process.memoryUsage().heapUsed + const result = await run( + `for (let i = 0; i < 50; i++) Promise.reject(new Error(i + "x".repeat(2 ** 22))); return 1`, + ) + Bun.gc(true) + expect(result.ok && result.warnings?.length).toBe(50) + expect(process.memoryUsage().heapUsed - before).toBeLessThan(50_000_000) + }) +})