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

Open 💬 0 comments Opened Aug 24, 2026 by fewd111111

---
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).

  1. 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).

  1. 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.

  1. 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
``

  1. 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.

  1. 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.

  1. 2026-08-23 — v1 tolerates EINVAL. Error becomes exited -1 in 0ms.
  2. 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.

  1. 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.

  1. 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.

  1. Consider the same guard-skip for other emulated/restricted Linux

environments where prctl(PDEATHSIG) may return EINVAL.

  1. 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.*

View original on GitHub ↗