iSH (iOS Linux emulator): non-TTY exec_command always fails with "exited -1 in 0ms" - SIGTERM from getppid()!=parent_pid guard
---
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 insidepre_exec, because of this guard incodex-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 doesraw_output.exit_status.code().unwrap_or(-1), so -1 meansExitStatus::code() == None, i.e. the child was killed by a signal
(WIFSIGNALED), not an exit code of -1.
---
3. Root-cause chain (evidence-backed)
- 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).
prctl(PR_SET_PDEATHSIG, SIGTERM)fails on iSH withEINVAL. Before any
patch, this error surfaced as
Failed to create unified exec process: Invalid argument (os error 22).
- Tolerating
EINVALremoved that error, but the exec still died with
exited -1 — the guard getppid() != parent_pid -> raise(SIGTERM) ran
anyway and killed the child.
- Instrumented binary (
[dbg-pipe]stderr logging inpipe.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
- 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.
- 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)+ optionalarg0current_dir,env_clear, minimalPATHpre_exec:setsid()(fallbacksetpgid(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 atokio::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)
- 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.
- 2026-08-23 — v1 tolerates EINVAL. Error becomes
exited -1 in 0ms. - 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.
- 2026-08-24 — instrument codex
pipe.rswith[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.
- 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
- 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.
- Consider the same guard-skip for other emulated/restricted Linux
environments where prctl(PDEATHSIG) may return EINVAL.
- 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.*