Skip to content

buffer: utilities to check whether a Buffer or string fits in memory before creating it #66062

Description

@mcollina

What is the problem this feature will solve?

A very common pattern is to accumulate streamed input (an HTTP body, a file, a socket) and then parse it fully in memory: Buffer.concat(chunks), buf.toString(), JSON.parse(text). Today the only defenses are static byte limits (bodyLimit in Fastify, limit in raw-body, and so on). There is no supported way to ask "will this allocation fit in the memory this process actually has?", so a limit that is fine on a laptop takes a 512 MB container down.

The building blocks exist (buffer.constants.MAX_LENGTH, buffer.constants.MAX_STRING_LENGTH, process.availableMemory(), v8.getHeapStatistics().total_available_size), but the rules for combining them are non-obvious and partly undocumented. I measured the following on Linux with main (v27.0.0-pre):

Buffer allocation has two failure modes, and only one is catchable.

  • When malloc returns NULL (for example under ulimit -v, or on Linux overcommit mode 0 for a single request larger than RAM+swap) Node throws a catchable RangeError ERR_MEMORY_ALLOCATION_FAILED.

  • Under a cgroup limit the allocation succeeds and the process is SIGKILLed on first write:

    $ systemd-run --user --scope -p MemoryMax=512M -p MemorySwapMax=0 node -e '
      console.log(process.availableMemory() / 2**20 | 0);   // 501
      const b = Buffer.allocUnsafe(2**30);                   // succeeds
      b.fill(1);'                                            // Killed, exit 137
    

    process.availableMemory() predicted it correctly. Nothing in the docs says to use it for this.

Decoded strings land in different memory pools depending on the input.

buf.toString('utf8') input Where the result lives
Valid UTF-8, result >= ~1M chars External (malloc), accounted in external_memory
Invalid UTF-8, any size V8 heap (String::NewFromUtf8 fallback in src/string_bytes.cc)
Result < ~1M chars (EXTERN_APEX) V8 heap

The same 16 MB body costs native memory when it is well formed and V8 heap when one byte is truncated. TextDecoder and stream/consumers use the same path.

The V8 heap limit is soft and its OOM is fatal. With --max-old-space-size=128 a decode that needs 267 MB of heap succeeds; the process dies with SIGABRT at the next GC that cannot get back under the limit. JSON.parse of a 60 MB array of small integers needs ~300 MB of heap and aborts. v8.getHeapStatistics().total_available_size (0.5 µs/call) is the only predictor, and it cannot see the object graph a parse will build.

The MAX_STRING_LENGTH check for latin1/ascii runs after the copy. ExternString::NewFromCopy in src/string_bytes.cc mallocs and copies the whole input, then String::NewExternalOneByte rejects it. Failing on a 512 MB input takes ~400 ms; a successful 100 MB decode takes ~46 ms. The UTF-8 paths check before doing work.

OS differences (libuv).

  • Linux: availableMemory() is cgroup v1/v2 aware and subtracts current usage (~30 µs/call).
  • macOS: availableMemory() is free + inactive + purgeable pages; constrainedMemory() only reflects RLIMIT_AS/RLIMIT_DATA, which malloc largely ignores.
  • Windows: constrainedMemory() is hard-coded to 0 (Job Object limits are invisible); availableMemory() is ullAvailPhys and ignores the commit limit. Allocation past the commit limit does fail synchronously, so at least it is catchable there.

V8 offers nothing to smooth this over: GetHeapStatistics, NearHeapLimitCallback, SetOOMErrorHandler and MemoryPressureNotification cannot reserve memory or answer "will N bytes fit".

Related: #64646 (heap_size_limit larger than the cgroup limit) is the same accounting split seen from the other side.

What is the feature you are proposing to solve the problem?

  1. buffer.stringLength(buf[, encoding]), the mirror of Buffer.byteLength(). Returns the number of UTF-16 code units a decode would produce, without decoding (simdutf utf16_length_from_utf8, ~10 GB/s; trivial for latin1/ascii/hex/base64). This lets a parser check against MAX_STRING_LENGTH and compute the 2 * length byte bound before doing any work. Whether the result can be one-byte is not needed for an upper bound.

  2. Check MAX_STRING_LENGTH before copying in the latin1/ascii decode path in src/string_bytes.cc. Independent, small fix.

  3. Document a memory-budgeting recipe in doc/api/buffer.md and doc/api/process.md rather than adding a boolean canAllocate(). A boolean would have to hide the headroom policy and the heap/native split, and would be wrong on macOS and Windows where the probes are weaker. The recipe:

    const { constants: { MAX_LENGTH, MAX_STRING_LENGTH }, stringLength } = require('node:buffer');
    const RESERVE = 64 * 2 ** 20;  // headroom for the rest of the process
    
    // (a) Buffer.alloc(n), (d) Buffer.concat(list) with n = total length
    //     (peak is 2 * n while the sources are alive)
    function fitsNative(n) {
      return n <= MAX_LENGTH && n + RESERVE <= process.availableMemory();
    }
    
    // (b) a string of `units` UTF-16 code units built in JS (concat, JSON.stringify, ...)
    function fitsHeap(units) {
      return units <= MAX_STRING_LENGTH &&
        units * 2 + RESERVE <= v8.getHeapStatistics().total_available_size;
    }
    
    // (c) buf.toString(): large valid UTF-8 is native, everything else is heap
    function canDecode(buf) {
      const units = stringLength(buf, 'utf8');
      return units <= MAX_STRING_LENGTH && fitsNative(units * 2) && fitsHeap(units);
    }

    Plus an explicit warning that nothing can bound JSON.parse, and that the V8 limit is enforced at GC time, not at allocation.

  4. libuv follow-up: read Job Object memory limits in uv_get_constrained_memory() on Windows, which is the one gap that makes the recipe wrong in Windows containers.

What alternatives have you considered?

  • buffer.canAllocate(bytes) / Buffer.canAlloc(): rejected for the reasons in (3). It cannot be made truthful for heap strings and hides OS-specific weakness.
  • A "reserve" API that pre-touches pages: would defeat lazy commit and cost a full write of the buffer.
  • Leaving it to userland: userland already does static limits; the information needed for dynamic ones is only partly exposed (stringLength does not exist) and the pool split is not documented anywhere.

A portable probe script that reproduces each measurement above (runs the risky steps in child processes) is available on request; I will attach results from macOS and Windows.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    feature requestIssues requesting new Node.js features.

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions