Sandbox¶
The V8 sandbox is a security boundary around V8's heap. Everything V8 allocates for JavaScript lives inside one reserved region of the virtual address space, and V8 treats every pointer inside that region as untrusted. A memory corruption bug in the JavaScript engine therefore cannot easily be turned into an attack on the rest of the process, because a corrupted pointer can only ever address memory that is already inside the sandbox.
Javet enables the sandbox in V8 mode on the desktop platforms. It costs almost nothing in physical memory and a great deal in virtual address space. That trade-off is invisible to most applications and surprising to a few, so this page describes what it costs, where the ceiling is, and how to stay under it.
Where the Sandbox Is Enabled¶
Platform |
Sandbox (V8 mode) |
|---|---|
Linux x86_64, arm64 |
Yes |
macOS x86_64, arm64 |
Yes |
Windows x86_64 |
Yes |
Android (all) |
No |
V8 Mode¶
Since v5.0.8, Javet runs V8 mode with multi-cage pointer compression. Every V8Runtime is created in its own v8::IsolateGroup, which gives it a private 4 GB pointer compression cage instead of sharing one cage across the process. The sandbox follows the cage: one sandbox per V8 runtime.
This is what makes the address space arithmetic below matter. It is also what makes it bounded and predictable, because the reservation is released when the runtime is closed.
Node.js Mode¶
Node.js mode does not use multi-cage pointer compression. All runtimes are allocated in a single shared isolate group, so the per-runtime multiplication described here does not apply.
What a Sandbox Costs¶
A sandbox is one contiguous reservation, sized entirely by compile-time V8 constants:
Part |
Size |
Purpose |
|---|---|---|
Leading guard region |
64 GB |
Absorbs out-of-bounds accesses below the sandbox |
The sandbox itself |
1024 GB |
Everything V8 allocates for JavaScript |
Trailing guard region |
288 GB |
Absorbs out-of-bounds accesses above the sandbox, including the worst case of a 32-bit index into an array of 64-bit values at a 32 GB offset |
Total per runtime |
1376 GB |
So every V8 mode runtime costs roughly 1.34 TiB of virtual address space.
Note
Reserved is not committed. The reservation is mapped with no access permissions and no backing store: it consumes no physical memory, no swap and no page table entries until V8 commits pages inside it. A process holding dozens of runtimes can show a virtual size in the tens of terabytes while its resident set stays in the hundreds of megabytes. Tools that report VSZ will look alarming and are not telling you about a memory leak.
How Many Runtimes Fit in a Process?¶
On 64-bit Linux the kernel hands out user mappings from a 128 TiB window by default, regardless of how much virtual address space the CPU can actually address. That window is the real limit:
128 TiB / 1.34 TiB per runtime ~= 95 runtimes
Call it roughly 90 to 100 concurrent V8 mode runtimes per process, less in practice because the reservations must be contiguous and 4 GB aligned, so fragmentation costs some of them. macOS and Windows are in the same order of magnitude.
This is a limit on concurrently live runtimes, not on runtimes created over the lifetime of the process. Closing a runtime releases its reservation immediately, so an application that closes what it opens can create millions of them and never come close to the ceiling.
What Happens at the Limit¶
V8 does not fail cleanly when the reservation cannot be satisfied. It halves the request and retries as a partially reserved sandbox, and that fallback path chooses a base address at random across the whole address space the CPU reports rather than staying in the low range the ordinary path uses.
On a CPU that reports 57-bit virtual addresses — five-level paging, common on recent server parts and on cloud instances built from them — that random address is very likely to land above the 248 boundary. Some of V8's internal pointer encodings have room for only 48 bits, so a heap address above that boundary is silently truncated on the way in. The truncated pointer is then dereferenced by the first script the runtime executes, and the process dies with a SIGSEGV inside the Javet native library.
Caution
This crash is a symptom of holding too many live runtimes. It is not caused by the script that happened to be running when it fired, and the crashing script is usually an innocent one that merely had the bad luck of being first into a newly created runtime. When you see a SIGSEGV inside the native library after a long-running process has created many runtimes, count the runtimes before you study the JavaScript.
Two conditions have to coincide — a CPU that reports more than 48 virtual address bits, and enough address space pressure to reach the fallback — which is why the failure is intermittent. A process may run for a long time, or crash on one machine and not another, on the same code.
Note
A SIGSEGV at createV8Runtime() is not necessarily this problem. See SIGSEGV at createV8Runtime() in V8 Mode on AWS for an unrelated crash with the same signature and a different cause.
Diagnosing¶
Count the live runtimes first. If the number grows over the lifetime of the process, the runtimes are being leaked and everything below is a confirmation rather than an investigation.
Count the sandbox reservations on Linux:
grep -c 'v8-sandbox' /proc/<pid>/maps
Read the peak virtual size, which is what actually runs out:
grep -E 'VmPeak|VmSize|VmRSS' /proc/<pid>/status
A JVM crash log tells the same story without a live process. Virtual Size far into the terabytes with a small Resident Set Size means the address space is exhausted while physical memory is not:
Process Memory:
Virtual Size: 96636764160K (peak: 96636764160K)
Resident Set Size: 655360K (peak: 655360K)
That is 90 TiB reserved against 640 MB resident: about 67 live runtimes, and only a handful of reservations away from the ceiling.
Staying Under the Limit¶
Close What You Open¶
A V8Runtime holds its reservation until it is closed. Use try-with-resources so that an exception cannot skip the close:
try (V8Runtime v8Runtime = V8Host.getV8Instance().createV8Runtime()) {
// The reservation is released at the end of the block.
}
Use the Engine Pool¶
An engine pool bounds the number of live runtimes by construction, which is the most reliable way to stay under the ceiling. Size it deliberately rather than deriving it from the host, and remember that the pool itself holds its runtimes until it is closed:
try (IJavetEnginePool<V8Runtime> javetEnginePool = new JavetEnginePool<>()) {
javetEnginePool.getConfig().setPoolMaxSize(8);
try (IJavetEngine<V8Runtime> javetEngine = javetEnginePool.getEngine()) {
V8Runtime v8Runtime = javetEngine.getV8Runtime();
// ...
}
// Engines return to the pool here, they are not destroyed.
}
// The pool is closed, and every runtime it held is released.
See Javet Engine Pool for the full configuration.
Caution
Several pools, each individually well sized, still add up. Applications that build a pool per tenant, per module or per test are the ones that reach the ceiling, because no single pool looks unreasonable. What matters is the total across the process.
Watch Out for Short-Lived Owners¶
Anything that creates a runtime or a pool in a setup step and relies on garbage collection to clean it up will leak the reservation, because nothing in the JVM heap is large enough to make the collector feel any urgency. The JVM sees a small object; the operating system sees 1.34 TiB. Test suites are a common example: a fresh runtime per test case, never closed, accumulates for the lifetime of the test JVM.
Other Things the Reservation Affects¶
ulimit -vcaps virtual memory, not physical memory. Any limit below about 1.4 TiB will stop a single V8 mode runtime from starting, and the failure looks like an out-of-memory error on a machine with plenty of memory free. Leave it unlimited and control memory withulimit -m, cgroups or container limits, which count resident pages.Container memory limits are resident-set based and are not affected by the reservation.
Monitoring that alarms on
VSZwill fire constantly. Alarm onRSSinstead, and if you want an early warning for this failure mode, track the number of live runtimes directly.