Repository navigation
Test that recursion checks hold with a large environment on Linux #44302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
robobun
wants to merge
6
commits into
main
Choose a base branch
from
robobun/dac66f05/stack-bounds-large-environment
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 2 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
ab93cf6
Bump WebKit (oven-sh/WebKit#556 preview): recursion checks hold with …
robobun d97cc7b
ci: retrigger
robobun 792c56d
stack-bounds test: match the whole stderr, raise the recursion limit,…
robobun aee6c82
Merge remote-tracking branch 'origin/main' into robobun/dac66f05/stac…
robobun 88a9d69
stack-bounds test: follow the WebKit that main pins
robobun 4fd82e2
stack-bounds test: the error column is where the check tripped
robobun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { bunEnv, bunExe, isDebug, isLinux, tempDir } from "harness"; | ||
| import { closeSync, openSync, readSync } from "node:fs"; | ||
|
|
||
| // oven-sh/WebKit#556. The Linux kernel lets the main thread's stack mapping reach RLIMIT_STACK, measured from the | ||
| // end of the mapping. The argument and environment strings are at that end. A bound measured from below them is too | ||
| // low by their size. Once they pass the reserve that a recursion check keeps, the kernel's limit comes first and | ||
| // the process gets SIGSEGV. The largest reserve is 512 KB (bun's checks in an ASAN build). | ||
| // | ||
| // Linux only. Windows and macOS give bun an 18 MB main stack at link time and do not measure it this way. Windows | ||
| // caps a variable at 32,767 characters, and macOS caps the arguments plus the environment at 1 MB. | ||
|
|
||
| // The kernel caps one string at 128 KB (MAX_ARG_STRLEN), so the 700 KB is seven variables. | ||
| const largeEnvironment = { | ||
| ...bunEnv, | ||
| ...Object.fromEntries( | ||
| Array.from({ length: 7 }, (_, i) => [`STACK_BOUNDS_PADDING_${i}`, Buffer.alloc(100_000, "x").toString()]), | ||
| ), | ||
| }; | ||
|
|
||
| // CI runs with an unlimited stack, where the kernel has no limit to come first. The limit must stay above four times | ||
| // the environment, or exec fails with E2BIG. | ||
| function withStackLimit(kilobytes: number, cmd: string[]) { | ||
| return ["sh", "-c", `ulimit -S -s ${kilobytes} && exec "$0" "$@"`, ...cmd]; | ||
|
robobun marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| async function run(cmd: string[], cwd?: string) { | ||
| await using proc = Bun.spawn({ cmd, cwd, env: largeEnvironment, stdout: "pipe", stderr: "pipe" }); | ||
| const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); | ||
| return { stdout, stderr, exitCode, signalCode: proc.signalCode }; | ||
| } | ||
|
|
||
| // The dynamic loader that an ELF file names in PT_INTERP, or undefined for a static executable. | ||
| function programInterpreter(path: string): string | undefined { | ||
| const fd = openSync(path, "r"); | ||
| try { | ||
| const header = Buffer.alloc(64); | ||
| readSync(fd, header, 0, header.length, 0); | ||
| const entrySize = header.readUInt16LE(0x36); | ||
| const table = Buffer.alloc(entrySize * header.readUInt16LE(0x38)); | ||
| readSync(fd, table, 0, table.length, Number(header.readBigUInt64LE(0x20))); | ||
| for (let at = 0; at < table.length; at += entrySize) { | ||
| const PT_INTERP = 3; | ||
| if (table.readUInt32LE(at) !== PT_INTERP) continue; | ||
| const name = Buffer.alloc(Number(table.readBigUInt64LE(at + 0x20))); | ||
| readSync(fd, name, 0, name.length, Number(table.readBigUInt64LE(at + 0x08))); | ||
| return name.toString("latin1", 0, name.indexOf(0)); | ||
| } | ||
| } finally { | ||
| closeSync(fd); | ||
| } | ||
| } | ||
|
|
||
| describe.concurrent.skipIf(!isLinux)("with 700 KB of environment", () => { | ||
| test("native recursion checks trip before the stack ends", async () => { | ||
| const script = ` | ||
| const open = Buffer.alloc(100_000, "[").toString(); | ||
| const close = Buffer.alloc(100_000, "]").toString(); | ||
| const walkers = { | ||
| "Bun.JSONC.parse": () => Bun.JSONC.parse(open + close), | ||
| "Bun.JSON5.parse": () => Bun.JSON5.parse(open + close), | ||
| "Bun.TOML.parse": () => Bun.TOML.parse("a = " + open + close), | ||
| "Bun.YAML.parse": () => Bun.YAML.parse(open + close), | ||
| "Bun.Transpiler": () => new Bun.Transpiler().transformSync("x = " + open + close), | ||
| }; | ||
| for (const [name, walk] of Object.entries(walkers)) { | ||
| try { | ||
| walk(); | ||
| console.log(name + ": no error"); | ||
| } catch (e) { | ||
| console.log(name + ": " + e.message); | ||
| } | ||
| } | ||
| `; | ||
| const { stdout, ...rest } = await run(withStackLimit(8192, [bunExe(), "-e", script])); | ||
| expect({ stdout: stdout.split("\n"), ...rest }).toEqual({ | ||
| stdout: [ | ||
| "Bun.JSONC.parse: Maximum call stack size exceeded.", | ||
| "Bun.JSON5.parse: Maximum call stack size exceeded.", | ||
| "Bun.TOML.parse: Maximum call stack size exceeded.", | ||
| "Bun.YAML.parse: Maximum call stack size exceeded.", | ||
| "Bun.Transpiler: Maximum call stack size exceeded", | ||
| "", | ||
| ], | ||
| stderr: "", | ||
| exitCode: 0, | ||
| signalCode: null, | ||
| }); | ||
| }); | ||
|
|
||
| // The define loader runs before the VM exists, on the bound that the main thread computes at start. | ||
| const deepDefine = () => | ||
| tempDir("stack-bounds-define", { | ||
| "bunfig.toml": `[define]\nK = '${Buffer.alloc(40_000, "[")}1${Buffer.alloc(40_000, "]")}'\n`, | ||
| "index.js": `console.log("ran");`, | ||
| }); | ||
| const deepDefineError = { stdout: "", exitCode: 1, signalCode: null }; | ||
|
|
||
| test("a deeply nested [define] value is an error", async () => { | ||
| using dir = deepDefine(); | ||
| const { stderr, ...rest } = await run(withStackLimit(8192, [bunExe(), "index.js"]), String(dir)); | ||
| expect(stderr).toContain("error: JSON document is too deeply nested"); | ||
| expect(rest).toEqual(deepDefineError); | ||
| }); | ||
|
|
||
|
robobun marked this conversation as resolved.
Resolved
|
||
| // glibc's loader points AT_EXECFN, which marks the end of the stack mapping, at argv[0]. | ||
| const loader = isLinux ? programInterpreter(bunExe()) : undefined; | ||
| test.skipIf(!loader)("a deeply nested [define] value is an error when the loader starts bun", async () => { | ||
| using dir = deepDefine(); | ||
| const { stderr, ...rest } = await run(withStackLimit(8192, [loader!, bunExe(), "index.js"]), String(dir)); | ||
| expect(stderr).toContain("error: JSON document is too deeply nested"); | ||
| expect(rest).toEqual(deepDefineError); | ||
| }); | ||
|
|
||
| // The RegExp parser checks the stack against JavaScriptCore's copy of the bound. A debug JavaScriptCore takes | ||
| // 0.2 ms for each group, so it gets fewer groups and a limit that fewer groups reach. | ||
| test("a deeply nested RegExp is a RangeError", async () => { | ||
| const [limit, groups] = isDebug ? [4096, 3_000] : [8192, 60_000]; | ||
| const script = ` | ||
| const open = Buffer.alloc(${groups * 3}, "(?:").toString(); | ||
| const close = Buffer.alloc(${groups}, ")").toString(); | ||
| try { | ||
| new RegExp(open + "a" + close); | ||
| console.log("no error"); | ||
| } catch (e) { | ||
| console.log(e.constructor.name + ": " + e.message); | ||
| } | ||
| `; | ||
| expect(await run(withStackLimit(limit, [bunExe(), "-e", script]))).toEqual({ | ||
| stdout: "RangeError: Out of memory: Invalid regular expression: too many nested disjunctions\n", | ||
| stderr: "", | ||
| exitCode: 0, | ||
| signalCode: null, | ||
| }); | ||
| }); | ||
|
|
||
| // JavaScriptCore keeps its own use of an 8 MB stack under 5 MB, so its limit comes from the bound only when | ||
| // RLIMIT_STACK is lower. | ||
| test("JS recursion is a RangeError under a 4 MB stack limit", async () => { | ||
| const script = ` | ||
| function recurse() { | ||
| return recurse() + 1; | ||
| } | ||
| try { | ||
| recurse(); | ||
| console.log("no error"); | ||
| } catch (e) { | ||
| console.log(e.constructor.name + ": " + e.message); | ||
| } | ||
| `; | ||
| expect(await run(withStackLimit(4096, [bunExe(), "-e", script]))).toEqual({ | ||
| stdout: "RangeError: Maximum call stack size exceeded.\n", | ||
|
robobun marked this conversation as resolved.
Resolved
|
||
| stderr: "", | ||
| exitCode: 0, | ||
| signalCode: null, | ||
| }); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.