Skip to content

[tfjs-node] Native tensor handles leak after signed int32 tensor ID rollover #8735

Description

@GideonKoenig

System information

  • Have I written custom code: Yes; a minimal standalone reproduction is included below.
  • OS Platform and Distribution: Debian 12, Linux x86-64.
  • TensorFlow.js installed from: npm.
  • TensorFlow.js version: @tensorflow/tfjs-node 4.22.0 / TFJS 4.22.0.
  • Node.js version: v22.23.1.
  • Backend: tensorflow CPU backend, bundled libtensorflow 2.9.1.

Describe the current behavior

tfjs-node assigns each native TFE_TensorHandle a monotonically increasing signed 32-bit ID. When the process creates its 2,147,483,648th native handle, the observed ID rolls from 2,147,483,647 to -2,147,483,648.

The JavaScript disposal path calls deleteTensor(id) only when id >= 0. Tensors created after the rollover are therefore removed from JavaScript bookkeeping, but their native handles remain in tfe_handle_map_. Native memory and getNumOfTFTensors() then grow monotonically while tf.memory().numTensors returns to baseline.

This was encountered in a real, long-running reinforcement-learning training service using tfjs-node. At approximately 22,377 handle allocations per second, the service reached the rollover after about 26.7 hours and then hit a native-memory cliff. Multiple production training runs failed on this timescale, including two controlled runs with identical configuration and seed that failed at the same training step. A captured core contained 195,793 live tensorflow::TensorHandle objects and 195,782 map IDs in the wrapped range beginning exactly at 0x80000000. The accelerated GDB reproduction below was created afterward to reproduce the same rollover without waiting more than a day.

The relevant current source appears to be:

Describe the expected behavior

Disposing tensors should release their native handles regardless of how many handles the process has created previously. A long-lived process with a bounded live tensor population should not leak after a cumulative allocation threshold.

Standalone code to reproduce the issue

The natural rollover requires 2,147,483,648 handle allocations. The following GDB-assisted reproduction moves the counter thirteen IDs below INT32_MAX in a disposable Linux x86-64 process and then runs ordinary tensor operations.

reproduce.js:

'use strict';

const tf = require('@tensorflow/tfjs-node');

function snapshot(iteration) {
    console.log(JSON.stringify({
        iteration,
        logicalTensorCount: tf.memory().numTensors,
        nativeTensorCount: tf.backend().getNumOfTFTensors(),
    }));
}

snapshot(0);
for (let iteration = 1; iteration <= 250; iteration++) {
    tf.tidy(() => {
        const input = tf.tensor1d([1, 2, 3, 4]);
        const output = input.add(iteration).square().sub(input).sum();
        output.dataSync();
    });
    if (iteration <= 10 || iteration % 25 === 0) snapshot(iteration);
}
snapshot('complete');

force-counter-near-overflow.gdb for the official 4.22.0 Linux x86-64 binary:

set pagination off
set confirm off
set breakpoint pending on
set debuginfod enabled off

break tfnodejs::TFJSBackend::InsertHandle(TFE_TensorHandle*)
commands 1
    silent
    set {int}($rdi + 0x78) = 2147483635
    printf "forced TFJSBackend::next_tensor_id_ to %d\n", {int}($rdi + 0x78)
    disable 1
    continue
end

run

Commands:

npm install @tensorflow/tfjs-node@4.22.0
node reproduce.js
gdb -q -batch -x force-counter-near-overflow.gdb --args node reproduce.js

Control result:

{"iteration":"complete","logicalTensorCount":0,"nativeTensorCount":0}

Counter-near-overflow result:

forced TFJSBackend::next_tensor_id_ to 2147483635
{"iteration":1,"logicalTensorCount":0,"nativeTensorCount":0}
{"iteration":2,"logicalTensorCount":0,"nativeTensorCount":1}
{"iteration":10,"logicalTensorCount":0,"nativeTensorCount":57}
{"iteration":100,"logicalTensorCount":0,"nativeTensorCount":687}
{"iteration":250,"logicalTensorCount":0,"nativeTensorCount":1737}
{"iteration":"complete","logicalTensorCount":0,"nativeTensorCount":1737}

After rollover, exactly seven native handles leak per loop while the logical count remains zero. The process exits normally.

Other information

Changing the JavaScript condition from id >= 0 to id !== -1 does not appear sufficient as a complete fix. It would allow deletion through the negative half-cycle, but continued rollover could eventually collide with IDs still used by persistent tensors.

A collision-safe reusable ID pool, or a wider end-to-end ID representation with an explicit non-ID sentinel, would avoid the cumulative allocation boundary. I can prepare a PR with the maintainers' preferred design and a regression test that forces the allocator near INT32_MAX, crosses the boundary, disposes the tensors, and verifies the native count returns to baseline.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions