Skip to content

[Linux Desktop][26.924] Electron runtime replaces libuv's SIGCHLD handler with an empty function: children are never reaped, so shell env times out, "Git is unavailable", threads never load #48554

Description

@axusnetworks

Summary

On Linux, ChatGPT Desktop 26.924.20706 and 26.924.22138 install an empty SIGCHLD handler in the main (browser) process after startup, replacing libuv's handler. libuv installs its SIGCHLD handler only once, when the first child is spawned, so from that point no child process is ever reaped. Every child_process promise in the main process (and its worker threads) waits forever.

This appears to be the root cause of #48419, #48397, #48225 and #48417, and likely of #48482. The same mechanism as #38505 (zombie accumulation on macOS) may be involved there too.

Environment

  • ChatGPT Desktop 26.924.22138 (build 11645, build commit 2e782921399a788a842b74ee1944a2e1e6e003a1), official .deb, x86-64
  • Ubuntu 26.04, Linux kernel 7.0, KDE Plasma
  • 26.917.71314 is not affected. Both builds bundle libuv 1.52.1; the Electron ("owl") runtime archive changed between them.

Symptoms (all explained by the cause)

  • Failed to load shell env caller=startup detail="Timed out after 5000ms." on every start (the login-shell probe finishes in ~0.2 s when run by hand)
  • Worktree archive will be retried errorMessage="Git is unavailable" although git works (the Git availability probe never gets its exit status)
  • Opening a local thread: thread_hydration ... outcome=failure reason=timeout; hydration stops after metadata_ready because prepare() awaits child processes; thread/resume is never sent
  • New messages are queued and new threads never start
  • ps shows zombie (Z) children of the ChatGPT main process: bash, git, ps, gh, tar, codex, growing over time

Evidence

  1. Zombies: ps -o pid,stat,comm --ppid <ChatGPT main pid> lists Z children for every short-lived helper.
  2. SIGCHLD delivered, nothing reaps it: a bpftrace probe on signal:signal_deliver (sig 17) for the main process shows SIGCHLD delivered to the main thread. No wait4/waitid follows, and the handler writes nothing to libuv's signal pipe.
  3. The installed handler is empty: the sa_handler address resolves to a CFI jump-table entry (0x117d9f78 in the 26.924.22138 ChatGPT binary), which jumps to an identical-code-folded empty function at 0x11ce69c8 (push rbp; mov rbp,rsp; pop rbp; ret).
  4. Where it is installed: the function at 0x723eb00 calls an initializer (0x723eb70). When that returns 0, it calls sigaction(17 /*SIGCHLD*/, {sa_handler = <empty>, sa_flags = 0}, NULL). The function is reached through pointers in .data.rel.ro (0x1244c698, 0x1244c758), i.e. a vtable. Addresses are virtual addresses in the unmodified 26.924.22138 binary.
  5. Knock-on effect: in the broken state SigCgt of the main process stays at 0x17381ecff. execa's signal-exit hooks remain registered because no child ever exits. With the fix below it returns to 0x1000104c8.

Controlled A/B test

Isolated headless instances of the installed build (Xvfb, private CODEX_HOME, Electron user data dir and TMPDIR, private copy of the bundled codex, no sign-in), 60 s each:

zombie children shell env app-server
stock 4-5 and rising Failed to load shell env connected
with preload guard 0 loads connected

On the real signed-in profile with the guard: saved threads render (including one with ~1,400 prior messages), messages send, new threads start, and 0 unreaped children remain after hours of use.

Workaround (no downgrade)

Preload a small library that refuses only a sigaction(SIGCHLD) call whose handler is an empty function while a real handler is already installed. Every other call passes through, and the library strips itself from LD_PRELOAD so child processes don't inherit it. Launch with LD_PRELOAD=/path/to/sigchld-guard.so /usr/lib/chatgpt/ChatGPT. Don't launch through the chatgpt shell wrapper: the wrapper's sh loads the library first, and the library removes the variable before the app starts. Build with gcc -O2 -fPIC -shared -o sigchld-guard.so sigchld-guard.c -ldl.

sigchld-guard.c (x86-64)
#define _GNU_SOURCE
#include <dlfcn.h>
#include <signal.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

typedef int (*sigaction_fn)(int, const struct sigaction *, struct sigaction *);

static sigaction_fn real_sigaction;

static sigaction_fn resolve(void)
{
    if (real_sigaction == NULL)
        real_sigaction = (sigaction_fn)dlsym(RTLD_NEXT, "sigaction");
    return real_sigaction;
}

/* Follow endbr64 and direct jumps (CFI jump tables) to the function body. */
static const unsigned char *body(const unsigned char *p)
{
    for (int hops = 0; hops < 4; hops++) {
        if (p[0] == 0xf3 && p[1] == 0x0f && p[2] == 0x1e && p[3] == 0xfa)
            p += 4;
        if (p[0] == 0xe9) {
            int32_t rel;
            memcpy(&rel, p + 1, sizeof rel);
            p += 5 + rel;
        } else if (p[0] == 0xeb) {
            p += 2 + (int8_t)p[1];
        } else {
            break;
        }
    }
    return p;
}

static int is_empty_function(void (*handler)(int))
{
    static const unsigned char framed_ret[] = {0x55, 0x48, 0x89, 0xe5, 0x5d, 0xc3};
    const unsigned char *p = body((const unsigned char *)handler);
    return p[0] == 0xc3 || memcmp(p, framed_ret, sizeof framed_ret) == 0;
}

static int is_real_handler(const struct sigaction *sa)
{
    if (sa->sa_flags & SA_SIGINFO)
        return sa->sa_sigaction != NULL;
    return sa->sa_handler != SIG_DFL && sa->sa_handler != SIG_IGN;
}

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact)
{
    sigaction_fn next = resolve();
    if (next == NULL)
        return -1;
    if (signum == SIGCHLD && act != NULL && !(act->sa_flags & SA_SIGINFO)
            && is_real_handler(act) && is_empty_function(act->sa_handler)) {
        struct sigaction current;
        if (next(SIGCHLD, NULL, &current) == 0 && is_real_handler(&current)) {
            if (oldact != NULL)
                *oldact = current;
            return 0;
        }
    }
    return next(signum, act, oldact);
}

__attribute__((constructor)) static void drop_from_environment(void)
{
    resolve();
    unsetenv("LD_PRELOAD");
}

Suggested fix

Don't install a SIGCHLD handler in the Electron runtime after Node/libuv has installed one. Or save and chain to the previous handler, or install it before Node's first spawn. The installer at 0x723eb00 appears to be new in the runtime shipped with 26.924.x.

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

    LinuxappIssues related to the Codex desktop appbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions