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 139The triggering image was a minimal Linux container that intentionally omitted both:
/etc/machine-id
/bin/shONNX 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:
nullptrThe 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 = 0x0and 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 initializationPR #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 crashingA 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() == nullptrso 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:
nullOwnership control block:
still existsCustom cleanup action:
still registeredThe source treated:
operator bool() == falseas though it also meant:
no cleanup will occurThat 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 resourceacquisition fails
→ wrapper owns nothing
→ destructor performs no releaseThe 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/shand often also provides:
/etc/machine-idThe affected chiseled image provided neither.
That combination mattered.
machine ID available
→ command fallback not neededmachine ID absent, shell available
→ fallback may executemachine ID absent, shell absent
→ popen() failsMinimal 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 crashThe process never reached:
SessionOptions
InferenceSession
model loading
execution-provider selection
inferenceThis 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.
| Configuration | Result |
|---|---|
| ONNX Runtime 1.27.1 or 1.28.0 in the chiseled image | Initialization succeeds |
| ONNX Runtime 1.29.0 in the same image | Deterministic SIGSEGV |
ONNX Runtime 1.29.0 with ORT_DISABLE_TELEMETRY=1 | Initialization and inference succeed |
| ONNX Runtime 1.29.0 with only intra-op threads set to one | SIGSEGV remains |
| Same 1.29.0 binary on full Ubuntu 24.04 | Initialization succeeds |
Two observations narrowed the cause.
Disable telemetry
→ failure disappearsand:
Keep telemetry but use a full Linux environment
→ failure disappearsChanging 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 logStage 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 scopeThat 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 cleanupThe 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 ownershipThe 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_telemetryrather 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 codeThis 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 reachedA 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 UIDBefore the patch:
telemetry enabled
→ deterministic SIGSEGV at OrtEnv.Instance()ORT_DISABLE_TELEMETRY=1
→ initialization succeedsThe 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 succeedstelemetry disabled
→ initialization succeedsThe 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-idand/bin/sh;telemetry’s legacy device-ID fallback called
popen();popen()returned null in the reproduced environment;a
shared_ptrcustom deleter subsequently calledpclose(nullptr);the native trace reached
_IO_new_fclosewith a null file pointer;replacing the owner with
unique_ptrallowed 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 requiredAcquisition fails
→ cleanup must not invent a resourceThe 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
ONNX Runtime PR #32226 — Prevent telemetry crashes in shell-less containers
ONNX Runtime Issue #32173 — SIGSEGV during
OrtEnvinitializationUpstream telemetry PR #1523 — Guard pipe cleanup when
popen()fails
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.