openai / openai/codex

iSH (iOS Linux emulator): non-TTY exec_command always fails with "exited -1 in 0ms" - SIGTERM from getppid()!=parent_pid guard

Open
#40,454 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug CLI exec
Dominant language
Rust
Stars
125k
Forks
19.4k
PR merge metrics
PR metrics pending

Description


title: >-
iSH (iOS Linux emulator): non-TTY exec_command always fails with
"exited -1 in 0ms" - child killed by SIGTERM from the
getppid() != parent_pid guard in set_parent_death_signal
repo_suggested: openai/codex
labels: [bug, linux, exec, process-group, iSH]
language: en
status: diagnosed and patched locally; not yet fixed upstream
last_verified: 2026-08-25

iSH: exec_command (non-TTY) always fails with exited -1 in 0ms; child is killed by SIGTERM from set_parent_death_signal

TL;DR

On iSH (the iOS Linux user-space emulator, kernel 4.20.69-ish, Alpine 3.21,
aarch64, musl), codex exec with a non-TTY exec_command fails 100% of the
time with exited -1 in 0ms, no stdout/stderr, and no side effects.

The child is spawned successfully, then raises SIGTERM on itself inside
pre_exec, because of this guard in
codex-rs/utils/pty/src/process_group.rs::set_parent_death_signal:

if unsafe { libc::getppid() } != parent_pid {
    unsafe { libc::raise(libc::SIGTERM); }
}

On iSH, prctl(PR_SET_PDEATHSIG, SIGTERM) returns EINVAL (the feature is
not implemented), so the "parent-death signal" is never armed. The guard is
then meaningless AND harmful: when the process is forked from a tokio worker
thread, iSH reports getppid() != parent_pid, so the child terminates itself.

Fix (verified): only run the ppid guard when PDEATHSIG was actually armed.
If prctl fails with EINVAL, skip the guard (there is no race to protect
against because the feature does not exist). Real Linux keeps the current
behavior unchanged. This is a local patch; it has not been submitted upstream.


1. Environment

Item Value
Device iPhone running iSH (iOS Linux user-space emulator)
uname Linux localhost 4.20.69-ish SUPER AWESOME ... aarch64 Linux
Distro Alpine 3.21, aarch64, musl
Shells /bin/sh -> busybox; /bin/bash = GNU bash
codex-cli 0.139.0 (codex --version)
Source tag rust-v0.139.0 (commit 27bf8ba17e includes local patch)
Build Rust 1.95.0, target aarch64-unknown-linux-musl, vendored tokio 1.52.3, zigbuild
Model access via a local TLS bridge (incidental to this bug; not required to reproduce)

iSH syscall quirks measured on this device:

Syscall / feature iSH result
prctl(PR_SET_PDEATHSIG, SIGTERM) EINVAL (errno 22); only PR_SET_KEEPCAPS / PR_SET_NAME work
waitid(P_PIDFD, ...) EINVAL, always (pidfd reaping unusable)
pidfd_open() succeeds (but the companion waitid path does not)
waitpid() / waitid(P_PID, ...) OK
setsid() / setpgid() OK (normal EPERM for a group leader)
fork() (single- and multi-threaded parent) OK in isolation
ptrace unsupported (PTRACE_SETOPTIONS EINVAL)
PTY job control incomplete (bash -i fails); TTY-based exec was NOT the failure mode here

2. Symptom

Reproduction command (on iSH):

CODEX_HOME=/path/to/codex-home \
  codex exec --skip-git-repo-check \
  "必須使用 exec_command 執行 echo hi,然後報告輸出"

Actual result (before fix), 100% of attempts:

exited -1 in 0ms

No output from the child, no file side effects, elapsed time ~0 ms.
exec_command with tty: true works. Pure chat / web-search / session memory
are unaffected.

Semantics of -1: codex-rs/core/src/exec.rs:770 does
raw_output.exit_status.code().unwrap_or(-1), so -1 means
ExitStatus::code() == None, i.e. the child was killed by a signal
(WIFSIGNALED), not an exit code of -1.


3. Root-cause chain (evidence-backed)

  1. Non-TTY spawn uses the pipe path:
    codex-rs/utils/pty/src/pipe.rs:125 calls
    process_group::set_parent_death_signal(parent_pid) inside pre_exec
    (same helper is used by the PTY path at codex-rs/core/src/spawn.rs:101).

  2. prctl(PR_SET_PDEATHSIG, SIGTERM) fails on iSH with EINVAL. Before any
    patch, this error surfaced as
    Failed to create unified exec process: Invalid argument (os error 22).

  3. Tolerating EINVAL removed that error, but the exec still died with
    exited -1 — the guard getppid() != parent_pid -> raise(SIGTERM) ran
    anyway and killed the child.

  4. Instrumented binary ([dbg-pipe] stderr logging in pipe.rs) proved the
    child was spawned and then killed:

    [dbg-pipe] spawn program="/bin/sh" args=[...] arg0=None
    [dbg-pipe] spawned pid=202
    [dbg-pipe] wait ok code=None signal=Some(15)      <- SIGTERM
    
  5. A 1:1 Rust clone of the codex pipe spawn structure (probe-v3) reproduced
    the failure only when forking from a tokio worker thread:

    Run Config Result
    r36 --kod --spawn-in-task (full pre_exec incl. ppid guard) code=None signal=Some(15), ELAPSED_MS=2, no output
    r37 --kod --spawn-in-task --no-ppid-check code=Some(0) signal=None, stdout=hi, ELAPSED_MS=326
    r38 --kod --spawn-in-task --no-preexec success

    The same probe WITHOUT --spawn-in-task (spawn from the main task) always
    succeeded on iSH. The thread context is the discriminator.

  6. Conclusion: after fork() from a tokio worker thread, iSH's emulated
    getppid() in the child does not equal the parent_pid captured before
    spawn. The guard misreads this as "parent died during fork/exec" and calls
    raise(SIGTERM) in the child.


4. Minimal reproducer (probe-v3)

Source: crates/probe-v3/src/main.rs (Rust, tokio multi-thread runtime).
It mirrors codex pipe.rs exactly:

  • Command::new(program) + optional arg0
  • current_dir, env_clear, minimal PATH
  • pre_exec: setsid() (fallback setpgid(0,0)), prctl(PDEATHSIG)
    tolerating EINVAL, optional ppid guard, close_inherited_fds_except
  • stdin=null, stdout/stderr=piped, kill_on_drop
  • --spawn-in-task: performs the whole spawn/wait inside a tokio::spawn
    task (worker thread) instead of the main task

Build for the device:

cargo zigbuild --target aarch64-unknown-linux-musl --release --bin probe-v3

Run on iSH:

/tmp/probe/probe-v3 --kod --spawn-in-task          # fails: signal 15
/tmp/probe/probe-v3 --kod --spawn-in-task --no-ppid-check  # succeeds
/tmp/probe/probe-v3 --kod --spawn-in-task --no-preexec     # succeeds

Key flags:

Flag Effect
--spawn-in-task fork from a tokio worker thread (required to reproduce)
--no-ppid-check skip the getppid() != parent_pid -> raise(SIGTERM) guard
--no-preexec skip the whole pre_exec block
--kod kill_on_drop(true) (matches codex)

5. The fix (local, verified)

File: codex-rs/utils/pty/src/process_group.rs

Before (buggy on iSH; this guard was introduced/intensified by upstream
commit 95af417 / PR #4200):

pub fn set_parent_death_signal(parent_pid: libc::pid_t) -> io::Result<()> {
    if unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) } == -1 {
        return Err(io::Error::last_os_error());
    }
    if unsafe { libc::getppid() } != parent_pid {
        unsafe { libc::raise(libc::SIGTERM); }
    }
    Ok(())
}

After:

pub fn set_parent_death_signal(parent_pid: libc::pid_t) -> io::Result<()> {
    let mut pdeathsig_armed = true;
    if unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) } == -1 {
        let err = io::Error::last_os_error();
        // iSH does not implement PR_SET_PDEATHSIG (EINVAL). Tolerate it:
        // degrade to "no parent-death-signal" instead of failing the spawn.
        if err.raw_os_error() != Some(libc::EINVAL) {
            return Err(err);
        }
        // PDEATHSIG was never armed, so there is no fork/exec race to guard
        // against. Running the guard anyway makes the child raise SIGTERM on
        // itself when iSH reports getppid() != parent_pid after fork from a
        // tokio worker thread.
        pdeathsig_armed = false;
    }
    if pdeathsig_armed && unsafe { libc::getppid() } != parent_pid {
        unsafe { libc::raise(libc::SIGTERM); }
    }
    Ok(())
}

Why this is safe on real Linux: prctl succeeds there, pdeathsig_armed
stays true, and the guard behaves exactly as before. The change only affects
environments where PDEATHSIG cannot be armed — where the guard protects
nothing and can only fire spuriously.

Suggested minimal upstream change (same logic, terser):

if unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) } == -1 {
    let err = io::Error::last_os_error();
    if err.raw_os_error() != Some(libc::EINVAL) {
        return Err(err);
    }
    return Ok(()); // PDEATHSIG not armed: no race to guard, and the ppid
                   // check is unreliable on iSH-like emulators
}
if unsafe { libc::getppid() } != parent_pid {
    unsafe { libc::raise(libc::SIGTERM); }
}
Ok(())

6. Verification after fix

Binary: patched codex-cli 0.139.0, aarch64 musl,
sha256 1f14900b9195e348c0ab86c8b18ebd87e1ed010c5f8ddc2692732eccdbcced6e.

Test Result
5 consecutive non-TTY exec_command echo hi 5/5, output hi, exit 0
File side effect (echo x > /tmp/fx.txt && cat) written + read back, exit 0
Web search real results with source URLs
Session memory (set value -> resume -> query) correct
PTY path unaffected (guard preserved on real Linux)

The same fix passed on a real Linux x86_64 build (control run), confirming no
regression for normal kernels.


7. Related upstream work

  • openai/codex issue #4198: "SIGTERM when calling from from pid 1"
    (2025-09). Same guard misfiring when the process is PID 1 in a container.
  • openai/codex commit 95af417 / PR #4200: changed the guard from
    if getppid() == 1 { raise(SIGTERM) } to
    if getppid() != parent_pid { raise(SIGTERM) }.
  • The guard introduced by #4200 is what misfires on iSH. #4200's logic is
    correct on real kernels (where prctl(PDEATHSIG) succeeds); it is wrong on
    iSH because the feature is not implemented, so the guard runs in a context
    where getppid() is unreliable.
  • No existing upstream issue covers the iSH / emulated-kernel /
    worker-thread-fork case.

8. Artifacts (binary hashes, for cross-checking)

Build sha256 Notes
Official 0.139.0 ed0f6efecf1ba42f4a3bc523d7bafa062451195ab47c02b60a93cb8d569ad2ce reproduces bug
v1 (tolerate EINVAL only) 923a31a92b15f53a3055896fa6fd727d1fea8b9a8dd9a7bc8cf878ac304fc03f still fails (exited -1)
v2 (+ tokio SIGCHLD fallback) f626dda1db933a39df4a8448ccfc8e53afe15b55e4fee491b6d10a25cc0c1440 still fails (exited -1)
v3 (final fix) 1f14900b9195e348c0ab86c8b18ebd87e1ed010c5f8ddc2692732eccdbcced6e passes all tests

Note: v3 also contains a vendored tokio tweak forcing the SIGCHLD reaper path,
because waitid(P_PIDFD) always returns EINVAL on iSH. That tweak alone did
not fix the bug (v2); the ppid-guard change is the actual fix for the SIGTERM.
On iSH, both changes are recommended.


9. Debug timeline (condensed, for context)

  1. 2026-08-22 — initial error:
    Failed to create unified exec process: Invalid argument (os error 22).
    C reproducer shows iSH prctl(PR_SET_PDEATHSIG) -> EINVAL.
  2. 2026-08-23 — v1 tolerates EINVAL. Error becomes exited -1 in 0ms.
  3. 2026-08-23/24 — eliminate pidfd, kill_on_drop, arg0, env_clear,
    fd-closing, thread-count, SIGCHLD and waitid(P_PIDFD) hypotheses with a
    matrix of C/Rust probes (30+ runs). All pass on the main thread.
  4. 2026-08-24 — instrument codex pipe.rs with [dbg-pipe] logs:
    spawned pid=202 then wait ok code=None signal=Some(15).
    probe-v3 --spawn-in-task reproduces 100%; A/B/C matrix (r36/r37/r38)
    isolates the ppid guard.
  5. 2026-08-24/25 — v3 fix; 5/5 exec + file write + web search + session
    memory all pass on the device.

10. Suggested next steps for maintainers

  1. Apply the fix in set_parent_death_signal (or the equivalent in
    core/src/spawn.rs for the PTY path) so the guard only runs when
    PDEATHSIG was actually armed.
  2. Consider the same guard-skip for other emulated/restricted Linux
    environments where prctl(PDEATHSIG) may return EINVAL.
  3. This report may also be relevant to iSH maintainers: getppid() after
    fork from a multithreaded parent is unreliable in iSH's syscall emulation,
    and waitid(P_PIDFD) / prctl(PR_SET_PDEATHSIG) are unimplemented.

Report generated from a real-device debug session (iSH on iPhone). No
personal data included. All measurements were collected on the device and
cross-checked against a local patch build.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in codex-rs/utils/pty/src/process_group.rs and trace its callers in codex-rs/utils/pty/src/pipe.rs:125 and codex-rs/core/src/spawn.rs:101. Reproduce the behavior with crates/probe-v3/src/main.rs using --spawn-in-task, then compare non-TTY execution on iSH with a real Linux build. Done means non-TTY commands complete successfully on iSH while preserving the existing real-Linux behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
linux, rust
Domain
cli, operating-systems
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.