ONNX Runtime #32226 — Why shared_ptr(nullptr, pclose) Crashed Shell-Less Containers

POSIX telemetry initialization, failed pipe ownership, and why checking a null pointer did not make its custom deleter safe

ONNX Runtime crashed before a model existed.

No InferenceSession had been created. No execution provider had run. No tensor had been allocated for inference.

The failure occurred here:

var env = OrtEnv.Instance();

In the documented environment, the process exited with:

SIGSEGV

exit code 139

The triggering image was a minimal Linux container that intentionally omitted both:

/etc/machine-id

/bin/sh

ONNX Runtime’s telemetry dependency could not obtain its usual device identifier, so it reached a fallback that attempted to run a shell command through popen().

popen() failed and returned:

nullptr

The code appeared prepared for that result:

std::shared_ptr<FILE> pipe(
    popen(cmd, "r"),
    pclose
);

if (!pipe) {
    return {};
}

The null check worked.

The cleanup did not.

A shared_ptr constructed with a custom deleter can retain that deleter even when its stored pointer is null. Returning from the function destroyed the local shared_ptr, which invoked:

pclose(nullptr)

The recorded native backtrace then entered glibc’s file-closing path with:

fp = 0x0

and crashed.

No machine ID
        ↓
legacy device-ID fallback
        ↓
popen() needs a shell
        ↓
shell is absent
        ↓
popen() returns null
        ↓
shared_ptr stores null plus pclose deleter
        ↓
function detects failure and returns
        ↓
shared_ptr destruction invokes pclose(nullptr)
        ↓
SIGSEGV during OrtEnv initialization

PR #32226 replaced that ownership with a unique_ptr.

auto close_pipe = [](FILE* file) {
    pclose(file);
};

std::unique_ptr<
    FILE,
    decltype(close_pipe)
> pipe(
    popen(cmd, "r"),
    close_pipe
);

When popen() returns null, the unique_ptr does not invoke its deleter.

The existing empty-result fallback can finally do what the source intended:

pipe unavailable

→ return no command output

→ continue initialization without crashing

A null check protects the code after acquisition. It does not repair an ownership object whose destruction still treats failed acquisition as a resource to release.


The smallest version of the defect

The complete telemetry path is not required to understand the ownership mismatch.

The essential sequence is:

std::shared_ptr<FILE> pipe(
    nullptr,
    pclose
);

if (!pipe) {
    return;
}

The condition sees:

pipe.get() == nullptr

so the function returns.

But the shared_ptr still has a control block containing the custom deleter.

At scope exit, that deleter receives the stored pointer:

pclose(nullptr);

The important distinction is:

Pointer value:
null
Ownership control block:
still exists
Custom cleanup action:
still registered

The source treated:

operator bool() == false

as though it also meant:

no cleanup will occur

That implication was false for this construction.


Why the null check looked sufficient

The code inside Exec() had a conventional shape:

auto resource = acquire();

if (!resource) {
    return fallback;
}

use(resource);

That pattern is normally safe when the resource wrapper’s empty state also has empty destruction semantics.

For example:

acquisition succeeds
→ wrapper owns resource
→ destructor releases resource
acquisition fails
→ wrapper owns nothing
→ destructor performs no release

The old shared_ptr construction did not satisfy the second half.

Its observable pointer state was empty.

Its cleanup state was not.

The function therefore reached the fallback correctly and still crashed while leaving it.


Why the fallback ran only in minimal environments

The telemetry SDK first attempts to obtain system information through ordinary files and platform mechanisms.

On the affected non-Apple path, if no device identifier has been found, it falls back to a command equivalent to:

echo `blkid; hostname`

The command output is hashed into a substitute identifier.

That fallback assumes a working shell environment.

A conventional Linux image commonly provides:

/bin/sh

and often also provides:

/etc/machine-id

The affected chiseled image provided neither.

That combination mattered.

machine ID available
→ command fallback not needed
machine ID absent, shell available
→ fallback may execute
machine ID absent, shell absent
→ popen() fails

Minimal and distroless containers omit shells deliberately to reduce image size and attack surface.

The absence of /bin/sh is not malformed application state.

A dependency performing optional system-information discovery must be able to encounter that environment and return no value.


Why the crash appeared before inference

The device-information probe ran during telemetry and logging initialization.

That work occurred while constructing ONNX Runtime’s global environment.

The sequence was approximately:

Load ONNX Runtime native library
        ↓
initialize OrtEnv
        ↓
initialize telemetry LogManager
        ↓
collect POSIX device information
        ↓
device-ID fallback enters Exec()
        ↓
cleanup crash

The process never reached:

SessionOptions

InferenceSession

model loading

execution-provider selection

inference

This explains why the model in the reproducer was irrelevant.

It was included only to show that inference succeeded after telemetry initialization was allowed to complete.

The failure belonged to library startup, not model execution.


The configuration matrix isolated telemetry

The reported comparison was unusually clean.

ConfigurationResult
ONNX Runtime 1.27.1 or 1.28.0 in the chiseled imageInitialization succeeds
ONNX Runtime 1.29.0 in the same imageDeterministic SIGSEGV
ONNX Runtime 1.29.0 with ORT_DISABLE_TELEMETRY=1Initialization and inference succeed
ONNX Runtime 1.29.0 with only intra-op threads set to oneSIGSEGV remains
Same 1.29.0 binary on full Ubuntu 24.04Initialization succeeds

Two observations narrowed the cause.

Disable telemetry
→ failure disappears

and:

Keep telemetry but use a full Linux environment
→ failure disappears

Changing the thread count did not help because thread creation was not the failing boundary.

The released 1.29.0 binary reached a telemetry path that older packages had not exercised in the same way.


Why no managed exception appeared

The C# application was not receiving an ordinary ONNX Runtime status failure.

The native process dereferenced an invalid FILE* inside cleanup.

A managed try/catch cannot convert an arbitrary native segmentation fault into an ONNX Runtime exception after the process has already entered invalid native behavior.

The issue reporter observed:

no managed exception

no useful native error on stderr

no completed startup log

Stage markers showed that execution stopped while touching:

OrtEnv.Instance()

The next marker—confirming that the native library had initialized—was never printed.

For a Kubernetes workload, that meant the container could simply terminate and restart without a model-specific diagnostic.


Why unique_ptr matches this resource

The function receives one FILE* from one call to popen().

It does not share that ownership with another object.

The resource contract is:

zero owners when acquisition fails

one owner when acquisition succeeds

one pclose when that owner leaves scope

That is a direct unique_ptr contract.

popen succeeds

→ unique_ptr stores a real FILE*

→ destructor invokes pclose(FILE*)
popen fails

→ unique_ptr stores null

→ destructor performs no cleanup

The custom lambda still uses pclose() for a valid pipe.

The patch changes failed-acquisition behavior, not successful pipe handling.


Why changing only the deleter would also have required a contract

A null-checking shared deleter could have been written:

auto close_pipe = [](FILE* file) {
    if (file) {
        pclose(file);
    }
};

That would avoid passing null to pclose().

But the function still has exactly one owner, so shared ownership adds no value.

The merged change uses the ownership type whose empty-state behavior already matches the resource.

One pipe

→ one owner

→ unique ownership

The repair is therefore more than a conditional around one unsafe call.

It aligns the C++ ownership model with the POSIX handle’s actual lifecycle.


ONNX Runtime carries the fix as a dependency patch

The faulty source belongs to:

cpp_client_telemetry

rather than ONNX Runtime’s own inference engine.

ONNX Runtime builds that dependency and already maintains a patch file for compatibility changes.

PR #32226 adds the null-safe ownership change there:

ONNX Runtime source tree

→ applies patch to telemetry dependency

→ builds corrected dependency code

This allowed ONNX Runtime to repair its released integration without waiting for a new upstream SDK version.

The corresponding upstream telemetry PR remains separate.

Until ONNX Runtime updates to an upstream release containing the same correction, the local compatibility patch remains meaningful.


Why ONNX Runtime’s later device-ID policy did not prevent the crash

ONNX Runtime replaces the SDK-generated identifier with its own persistent hashed device ID after telemetry initialization.

But the dependency’s automatic device-information probe ran earlier.

SDK initializes LogManager

→ SDK tries to discover device ID

→ failing shell probe occurs

→ process crashes

→ ONNX Runtime’s later override is never reached

A later correct value cannot protect an earlier unsafe acquisition path.

The PR notes that a future SDK option could allow ONNX Runtime to suppress this unnecessary automatic probe.

That would remove the path from this integration.

The cleanup still needs to remain null-safe for other SDK consumers and for any other reason popen() may fail.


Validation used the released failing environment

The repair was not validated only through source inspection.

The PR reports reproduction with:

Microsoft.ML.OnnxRuntime 1.29.0

linux-x64

mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled

default container UID

Before the patch:

telemetry enabled
→ deterministic SIGSEGV at OrtEnv.Instance()
ORT_DISABLE_TELEMETRY=1
→ initialization succeeds

The failing native trace identified:

pclose(nullptr)

→ _IO_new_fclose(fp=0x0)

The telemetry-enabled Linux shared library was then rebuilt with the unique_ptr change and substituted into the same managed reproducer.

After the patch:

telemetry enabled
→ initialization succeeds
telemetry disabled
→ initialization succeeds

The same minimal filesystem that exposed the defect validated the fix.


The patch is intentionally small

The merged ONNX Runtime change modifies one dependency patch file.

Its functional source change is:

- std::shared_ptr<FILE> pipe(
-     popen(cmd, "r"),
-     pclose
- );

+ auto close_pipe = [](FILE* file) {
+     pclose(file);
+ };
+
+ std::unique_ptr<
+     FILE,
+     decltype(close_pipe)
+ > pipe(
+     popen(cmd, "r"),
+     close_pipe
+ );

It does not change:

  • model loading,

  • CPU execution,

  • telemetry enablement policy,

  • event upload behavior,

  • thread-pool configuration,

  • device-ID hashing,

  • or the shell command itself.

The patch repairs ownership after a failed popen() call.


What the evidence confirms

The available record confirms that:

  • ONNX Runtime 1.29.0 deterministically crashed at OrtEnv.Instance() in the documented chiseled Linux image;

  • disabling telemetry avoided the crash;

  • the failure occurred before session or model construction;

  • the image lacked both /etc/machine-id and /bin/sh;

  • telemetry’s legacy device-ID fallback called popen();

  • popen() returned null in the reproduced environment;

  • a shared_ptr custom deleter subsequently called pclose(nullptr);

  • the native trace reached _IO_new_fclose with a null file pointer;

  • replacing the owner with unique_ptr allowed the existing empty-result fallback to continue;

  • the patched library initialized successfully in the same environment;

  • and the fix is merged into ONNX Runtime main.


What the evidence does not establish

The public record does not establish that:

  • every Linux container running ONNX Runtime 1.29.0 crashes;

  • telemetry upload itself caused the fault;

  • a model or execution provider triggered it;

  • every libc handles pclose(nullptr) identically;

  • Windows, macOS, or Apple telemetry paths were affected;

  • telemetry data was corrupted or disclosed;

  • the failure was remotely exploitable;

  • or the fix was already included in a released package at the time of merge.

The confirmed scope is narrower:

The telemetry-enabled 1.29.0 Linux package could crash during environment initialization when a minimal image caused the POSIX shell fallback to return a null pipe and the registered shared-pointer deleter passed it to pclose().


The contract that remains

Resource acquisition can fail before an owner exists.

The wrapper used for that resource must represent both states correctly.

Acquisition succeeds

→ cleanup is required
Acquisition fails

→ cleanup must not invent a resource

The old source correctly detected a null FILE*.

It represented that null pointer with an ownership object whose control block still promised to run pclose.

A failed acquisition is not a resource with a null address. It is the absence of a resource—and its owner must preserve that distinction through destruction.

That is the startup contract repaired by ONNX Runtime #32226.


Related material


Patch status: Merged into ONNX Runtime main as 9aa8fb06; version 1.29.0 is the documented affected release, and the maintainer said the fix should be included in the next release.


Popular posts from this blog

AMD Is Trying to Turn Kernel Diversity Into One Software Asset

PyTorch #188031 — Why Did Forward Use 64-Bit Indexing While Backward Still Used uint32_t?

NVIDIA CUTLASS #3017 — Why load() and store() Mapped the Same Tile Coordinate to Different Addresses