Skip to content

Failed createdump exec on Unix unlinks the target runtime's diagnostic socket #133733

Description

@mdh1418

On Unix, if a dump requested through the diagnostics IPC protocol reaches the runtime but the runtime cannot execute createdump, the temporary forked child runs inherited CoreCLR/PAL shutdown cleanup before exiting.

That cleanup unlinks the original target runtime's diagnostic socket and debugger pipe paths even though the target process remains alive. As a result, the first dotnet-dump collect reports the expected createdump launch failure, but subsequent diagnostic tools can no longer connect to the target through its default diagnostic port.

The issue has been reproduced on Linux. The faulty lifecycle is in the Unix fork()/execve() path and may also apply to other Unix targets that use this path. Windows does not use fork() and cannot encounter this specific inherited-child-cleanup failure.

Reproduction

Run a .NET application whose colocated createdump exists but does not have execute permission:

-rw-r--r-- 1 root root 110376 Sep 10 19:01 /app/createdump

In this example, the target is in another PID and mount namespace. Its host PID is 43751, while its namespace PID is 11.

Before dump collection, the target runtime's IPC paths exist:

PS /src> ls -l /proc/43751/root/tmp/
total 14180
drwxr-xr-x 2 root root    4096 Sep 11 14:47 MetricsExtensionData
prwx------ 1 1654 1654       0 Sep 11 17:02 clr-debug-pipe-11-66451340-in
prwx------ 1 1654 1654       0 Sep 11 17:02 clr-debug-pipe-11-66451340-out
srw------- 1 1654 1654       0 Sep 11 17:02 dotnet-diagnostic-11-66451340-socket
-rw-r--r-- 1 root root       0 Sep 11 18:22 healthy
-rw------- 1 1654 1654   33281 Sep 11 14:42 jit-10.dump
-rw------- 1 1654 1654   33281 Sep 11 17:02 jit-11.dump
-rw-r--r-- 1 1654 1654 7291146 Sep 11 17:01 perf-10.map
-rw-r--r-- 1 1654 1654 7145535 Sep 11 18:21 perf-11.map

Request a dump:

PS /src> /usr/bin/EP/diag-tools/dotnet-dump collect --process-id 43751
Writing full to /src/core_20260911_182444
Problem launching createdump (may not have execute permissions): execve(/app/createdump) FAILED Permission denied (13)

After the failed request, the managed process is still alive, but its runtime-owned IPC paths have disappeared:

PS /src> ls -l /proc/43751/root/tmp/
total 14212
drwxr-xr-x 2 root root    4096 Sep 11 14:47 MetricsExtensionData
-rw-r--r-- 1 root root       0 Sep 11 18:24 healthy
-rw------- 1 1654 1654   33281 Sep 11 14:42 jit-10.dump
-rw------- 1 1654 1654   33281 Sep 11 17:02 jit-11.dump
-rw-r--r-- 1 1654 1654 7291146 Sep 11 17:01 perf-10.map
-rw-r--r-- 1 1654 1654 7177606 Sep 11 18:23 perf-11.map

The following paths were removed:

/tmp/clr-debug-pipe-11-66451340-in
/tmp/clr-debug-pipe-11-66451340-out
/tmp/dotnet-diagnostic-11-66451340-socket

Expected behavior

The dump request should fail and report that createdump could not be executed, but the target process's diagnostic listener and debugger pipes should remain available for subsequent diagnostic operations.

Actual behavior

The target remains alive, but the default diagnostic socket and debugger pipe paths are unlinked. Subsequent tools cannot establish a new connection to the target through the default diagnostic port.

Root cause

The dump request is sent to the original target runtime. The runtime then forks a temporary child in PROCCreateCrashDump and expects that child to become createdump:

dotnet-dump
    |
    | GenerateCoreDump over target's diagnostic socket
    v
target CoreCLR process
    |
    | fork()
    +----------------------------+
    |                            v
    |                    forked CoreCLR child
    |                            |
    |                            | execve("/app/createdump", ...)
    |                            | fails with EACCES
    |                            v
    |                          exit(-1)
    |                            |
    |                            | inherited shutdown callback
    |                            v
    |                    unlink diagnostic paths
    |
    +-- remains alive, but its socket pathname is gone

In src/coreclr/pal/src/thread/process.cpp, the external createdump launch failure uses exit(-1):

if (execve(argv[0], (char**)argv, palEnvironment) == -1)
{
    fprintf(stderr, "Problem launching createdump (may not have execute permissions): execve(%s) FAILED %s (%d)\n", argv[0], strerror(errno), errno);
    exit(-1);
}

Because execve failed, the child is still a forked copy of the runtime. Calling exit() runs inherited process destructors. The PAL shutdown destructor invokes the inherited g_shutdownCallback, which CoreCLR configured as EESocketCleanupHelper.

EESocketCleanupHelper cleans up the debugger transport and calls DiagnosticServerAdapter::Shutdown(). Diagnostic server shutdown unlinks the Unix-domain socket path. Since the parent and child share the same filesystem namespace, the child removes the pathname used to reach the still-running parent.

The diagnostic socket descriptors already use SOCK_CLOEXEC or FD_CLOEXEC, so successful execve closes the child's inherited descriptor references without affecting the parent. The issue is specific to the failed-exec path running inherited userspace shutdown handlers.

There is already similar protection in the statically linked createdump path:

// Set the shutdown callback to nullptr and exit
// If we don't exit, the child's execution will continue into the diagnostic server behavior
// which causes all sorts of problems.
g_shutdownCallback = nullptr;
exit(callbackResult);

The external execve failure path does not clear the callback.

Proposed fix

Terminate the forked child with _exit() when execve fails:

if (execve(argv[0], (char**)argv, palEnvironment) == -1)
{
    fprintf(stderr, "Problem launching createdump (may not have execute permissions): execve(%s) FAILED %s (%d)\n", argv[0], strerror(errno), errno);
    _exit(EXIT_FAILURE);
}

_exit() performs kernel-level process cleanup, including closing the child's file descriptors and signaling EOF on the stderr pipe, but does not run inherited atexit handlers, static destructors, or the CoreCLR/PAL shutdown callback.

The parent can continue to:

  1. Read the launch error from the anonymous stderr pipe.
  2. Reap the failed child with waitpid().
  3. Return the failure through the existing diagnostics IPC connection.
  4. Keep its diagnostic listener and debugger pipe paths available.

If preserving the current fprintf(stderr, ...) output is a concern because _exit() does not flush C streams, the error should be written directly to the child stderr file descriptor or otherwise emitted before _exit().

Clearing g_shutdownCallback before exit() would address the currently observed callback, but _exit() is safer for a post-fork() exec-failure path because it prevents all inherited userspace cleanup from running against copied multithreaded-runtime state.

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

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions