Skip to content
Draft
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
1 change: 1 addition & 0 deletions JSTests/BUN-TEST-DIFFERENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ Not upstream's: a sync neither brings nor changes them. Most are named `stress/b
|---|---|
| `stress/bun-async-context-propagation.js` | the embedder's async-context slot is captured when a job is scheduled and current while it runs |
| `stress/bun-aot-builtins-table.js` | the ahead-of-time compiler's table of built-ins (`Source/JavaScriptCore/aot/builtins/*.builtins.ts`) agrees with the engine. **After a sync that removes, renames, moves or reimplements a built-in that the table knows this fails, and each line it prints names the entry to fix.** |
| `complex/bun-stack-bounds-unreadable-stack-limit.js` (with its entry in `complex.yaml`, upstream's file, of which nothing else changes, and the helper `Tools/Scripts/jsc-stress-test-helpers/deny-prlimit64`) | under a seccomp filter that makes `prlimit64()` fail, so that `getrlimit(RLIMIT_STACK)` cannot be read, and with a stack limit of 8 MB, the main thread's bound holds a nest of 10,000 groups in a regular expression and refuses one of 100,000 |
| `stress/sort-scratch-double-pivot.js` | double arithmetic in the comparator of an inlined sort stores no double into an Int32 array |
| `stress/array-sort-inline-int32-double-comparator.js` | an earlier, smaller test for the sort fix |
| `stress/bun-object-assign-calls-indexed-setters-on-the-chain.js` | `Object.assign()` stores with `[[Set]]` on its fast path too: an indexed setter on the target's prototype chain is called, and a read-only element there refuses the store |
Expand Down
15 changes: 15 additions & 0 deletions JSTests/complex.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,21 @@
runComplexTest [], [], "TZ=Etc/Unknown", "--useDollarVM=1", "--useTemporal=1"
end

- path: complex/bun-stack-bounds-unreadable-stack-limit.js
cmd: |
# Bun: the fork's own test. The helper runs jsc with a stack limit of 8 MB, under a seccomp filter that makes prlimit64() fail:
# jsc cannot read the limit. The helper is asked first whether it can do that here. Not under ASan, whose own start reads the
# limit and aborts. Not in a bundle for another machine, about which this one's answer says nothing.
helper = "deny-prlimit64"
errnos = [["eperm", 1], ["enosys", 38]]
if ($hostOS == "linux" && !$asan && !$tarball && errnos.all? { |_, errno| system("ruby", (HELPERS_PATH + helper).to_s, errno.to_s, "true", :err => File::NULL) })
errnos.each { |name, errno|
addRunCommand("complex-" + name, ["ruby", (pathToHelpers + helper).to_s, errno.to_s] + vmCommand + BASE_OPTIONS + $testSpecificRequiredOptions + [$benchmark.to_s], noisyOutputHandler, simpleErrorHandler)
}
else
skip
end

- path: complex/for-in-clobberize.js
cmd: runComplexTest [], [], nil, "--destroy-vm"

Expand Down
22 changes: 22 additions & 0 deletions JSTests/complex/bun-stack-bounds-unreadable-stack-limit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// complex.yaml runs this on Linux with a stack limit of 8 MB, under a seccomp filter that makes prlimit64() fail: jsc cannot read the limit.
// The RegExp parser checks the native stack against the bound of the main thread. 8 MB hold about 34,000 of these groups.

function compile(depth) {
try {
new RegExp("(?:".repeat(depth) + "a" + ")".repeat(depth));
return "compiled";
} catch (e) {
return String(e);
}
}

// A bound above the stack pointer, or close below it, refuses these.
const shallow = compile(10000);
if (shallow !== "compiled")
throw new Error("10000 groups: expected compiled, got " + shallow);

// A bound below the end of the stack lets these run past it.
const deep = compile(100000);
const expected = "RangeError: Out of memory: Invalid regular expression: too many nested disjunctions";
if (deep !== expected)
throw new Error("100000 groups: expected " + expected + ", got " + deep);
17 changes: 12 additions & 5 deletions Source/WTF/wtf/StackBounds.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ StackBounds StackBounds::currentThreadStackBoundsInternal()
// FIXME: <rdar://problem/13741204>
// pthread_get_size lies to us when we're the main thread, use get_rlimit instead
void* origin = pthread_get_stackaddr_np(pthread_self());
rlimit limit;
// A call that fails writes nothing, and a sandbox profile can make it fail. The limit then counts as no limit.
rlimit limit { RLIM_INFINITY, RLIM_INFINITY };
getrlimit(RLIMIT_STACK, &limit);
rlim_t size = limit.rlim_cur;
if (size == RLIM_INFINITY)
Expand Down Expand Up @@ -166,8 +167,10 @@ static void* endOfMainThreadStack()

StackBounds StackBounds::currentThreadStackBoundsInternal()
{
#if OS(LINUX) && USE(BUN_JSC_ADDITIONS)
#if OS(LINUX)
bool isMainThread = getpid() == static_cast<pid_t>(syscall(SYS_gettid));
#endif
#if OS(LINUX) && USE(BUN_JSC_ADDITIONS)
void* endOfStack = isMainThread ? endOfMainThreadStack() : nullptr;
auto ret = endOfStack ? StackBounds { endOfStack, endOfStack } : newThreadStackBounds(pthread_self());
#else
Expand All @@ -179,10 +182,14 @@ StackBounds StackBounds::currentThreadStackBoundsInternal()
// on musl, it will return the currently reserved size - since the stack bounds are expected to
// be constant (and they are for every thread except main, which is allowed to grow), check
// resource limits and use that as the boundary instead (and prevent stack overflows in JSC)
if (getpid() == static_cast<pid_t>(syscall(SYS_gettid))) {
if (isMainThread) {
// A seccomp filter can deny prlimit64(), which getrlimit() calls, and a call that fails writes nothing.
// The limit then counts as no limit, as it does to glibc when it sizes the stacks of threads.
rlimit limit { RLIM_INFINITY, RLIM_INFINITY };
// glibc and bionic need the limit for this thread's stack too. With neither, nothing is known of the stack.
if (getrlimit(RLIMIT_STACK, &limit) && !ret.m_bound)
return emptyBounds();
void* origin = ret.origin();
rlimit limit;
getrlimit(RLIMIT_STACK, &limit);
rlim_t size = limit.rlim_cur;
if (size == RLIM_INFINITY)
size = 8 * MB;
Expand Down
58 changes: 58 additions & 0 deletions Tools/Scripts/jsc-stress-test-helpers/deny-prlimit64
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env ruby

# Copyright (C) 2026 Oven, Inc. All rights reserved.
# SPDX-License-Identifier: BSD-2-Clause

# Bun: the fork's own helper. It runs a command under a seccomp filter that makes prlimit64() fail, which is what
# getrlimit() calls on Linux, so the command cannot read its resource limits. Before that it gives the command a
# stack limit of 8 MB, which is what WTF takes for a stack limit that it cannot read.
#
# usage: deny-prlimit64 <errno> <command> [argument...]
#
# It exits with 77 where it cannot do that: another system or architecture, a ruby without fiddle, a hard stack
# limit below 8 MB, a sandbox that forbids the filter. It exits with 78 if getrlimit() still works under the filter.

require "rbconfig"

# The numbers of prlimit64, seccomp and prctl, and the AUDIT_ARCH of the architecture.
numbers = { "x86_64" => [302, 317, 157, 0xc000003e], "aarch64" => [261, 277, 167, 0xc00000b7] }[RbConfig::CONFIG["host_cpu"]]
exit 77 unless RbConfig::CONFIG["host_os"].include?("linux") && numbers
begin
require "fiddle"
rescue LoadError
exit 77
end
prlimit64, seccomp, prctl, arch = numbers
errno = Integer(ARGV.shift)

megabyte = 1024 * 1024
hardLimit = Process.getrlimit(Process::RLIMIT_STACK)[1]
exit 77 if hardLimit < 8 * megabyte
Process.setrlimit(Process::RLIMIT_STACK, 8 * megabyte, hardLimit)

# struct sock_filter is { u16 code; u8 jt; u8 jf; u32 k; }.
instruction = ->(code, jt, jf, k) { [code, jt, jf, k].pack("SCCL") }
allow = instruction[0x06, 0, 0, 0x7fff0000]
instructions = [
instruction[0x20, 0, 0, 4], # Load the architecture of the call.
instruction[0x15, 1, 0, arch], # Another architecture has other numbers:
allow, # allow its calls.
instruction[0x20, 0, 0, 0], # Load the number of the call.
instruction[0x15, 0, 1, prlimit64], # prlimit64
instruction[0x06, 0, 0, 0x00050000 | errno], # fails with errno.
allow, # Any other call is allowed.
]
filter = Fiddle::Pointer[instructions.join]
# struct sock_fprog is { unsigned short len; struct sock_filter* filter; }.
program = Fiddle::Pointer[[instructions.size, filter.to_i].pack("Sx6Q")]
syscall = Fiddle::Function.new(Fiddle.dlopen(nil)["syscall"], [Fiddle::TYPE_LONG] * 6, Fiddle::TYPE_LONG)
# PR_SET_NO_NEW_PRIVS, then SECCOMP_SET_MODE_FILTER.
exit 77 unless syscall.call(prctl, 38, 1, 0, 0, 0) == 0 && syscall.call(seccomp, 1, 0, program.to_i, 0, 0) == 0

begin
Process.getrlimit(Process::RLIMIT_STACK)
exit 78
rescue SystemCallError
end

exec(*ARGV)
Loading