Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Cross-Language Comparison: Process, Signals, and Subprocess Semantics

This chapter compares the eight language chapters of this part side by side without assuming their semantics are equivalent. Every row below traces to a verified claim in one of the part chapters, each of which documented its behavior against a declared primary source; where two chapters’ verified facts disagree (or one corrects the other), the discrepancy is stated, not smoothed over. The exit/signal-scoped comparison in the Linux part (../../linux/process-exit-signal/languages/comparison.md) owns status decoding and the 128+N convention in depth; this chapter cross-links it and stays on the language/runtime layer.

How to read it. Nine dimensions, one ## section each, in the order the part’s chapters cover them: termination doors (§1), cleanup guarantees (§2), failure statuses (§3), signals (§4), subprocesses (§5), child-status types (§6), pipes and streams (§7), threading restrictions (§8), portability and versions (§9). Each section pairs a comparison table with an “asymmetries that bite” paragraph — the table is the lookup, the prose is the part where equivalent-looking APIs diverge. Citations are compact: ([c.md §3.2](./c.md), POSIX.1-2024 _Exit) means “verified in c.md §3.2 against POSIX.1-2024’s _Exit page”; follow the chapter for the full signature and ledger row. A guarantee stated here is exactly as strong as its chapter’s label — [std lib documented] is a contract, [HotSpot implementation]/ [runtime implementation] is observable behavior of one implementation, and source-limited means the chapter said so explicitly (see Recorded discrepancies).

The eight declared versions (one line each; full source sets in References):

  • C — ISO C17 (draft N2310) baseline with C23 deltas (N3220), glibc 2.44, POSIX.1-2024, Linux man-pages 6.19 (c.md, fetched 2026-10-13).
  • C++ — ISO C++23 DIS N4950 (C++26 working draft cross-checked), GCC 14.2.0 libstdc++ headers, glibc 2.44, POSIX.1-2024 (cpp.md, retrieved 2026-10-13; cppreference was blocked, signatures transcribed from N4950).
  • Python — CPython 3.15.0 (docs.python.org/3, CPython source, PEPs 442/446/475) (python.md, retrieved 2026-10-13).
  • Go — Go 1.27.2 (go.dev/dl JSON feed; pkg.go.dev doc comments at tag go1.27.2; Go source for implementation facts) (go.md, retrieved 2026-10-13).
  • Java — JDK 25 LTS (Javadoc, JLS SE 25, JEPs, jdk-25+36 source spot-checks) (java.md, retrieved 2026-10-13).
  • Rust — std 1.99.0 (build b940084d7, 2026-09-28; doc.rust-lang.org std/Reference/Cargo Book; std source for numeric anchors) (rust.md, retrieved 2026-10).
  • Node.js — v24.21.0 LTS “Krypton” (nodejs.org/docs/latest-v24.x/api, event-loop guide, libuv 1.52.1) (nodejs.md, retrieved 2026-10-13).
  • OCaml — OCaml 5.5.1 (source tree at tag 5.5.1, ocaml.org/manual/5.5) (ocaml.md, verified 2026-10-13).

Grounding statement: every claim below was verified in the cited chapter against the chapter’s declared official source during the 2026-10 retrieval window. Facts that a chapter could only source-implement (labeled [HotSpot implementation], [runtime implementation], source-limited) are relabeled here the same way — the label survives the comparison.


1. Normal return and explicit process termination

All eight runtimes convert a returned entry point into the same OS mechanism — a status byte delivered to the parent — but who converts, and what runs first, differs at every layer (c.md §3.1–3.5, ISO C23 N3220 §5.1.2.3.4/§7.24.4; cpp.md §3.1–3.2, N4950 [basic.start.main]/ [support.start.term]).

LanguageEntry-point return means…Who builds the OS statusExplicit exit familyTruncation to wait-status
CEquivalent to exit(main_value); falling off the end returns 0 (defined)C runtime exit → _exit → exit_group(2) [Linux]exit / _Exit (C99+) / quick_exit (C11+) / _exit (POSIX) / abortstatus & 0xFF for wait/waitpid; full value via waitid/SIGCHLD siginfo_t (c.md §3.5, POSIX _Exit)
C++Destroy main’s automatic objects, then std::exit(v) — only main’s frame unwindsSame C substratestd::exit / std::quick_exit / std::_Exit / std::abortSame 8-bit rule (cpp.md §3.4, POSIX _Exit)
PythonMain module completes → interpreter shutdown → C exit(0)CPython Py_FinalizeEx → C exit(sts)sys.exit(arg) (raises SystemExit), os._exit(n) (raw), os.abort()OS truncates int mod 256; docs recommend 0–127; non-int arg prints to stderr and exits 1 (python.md §3.2, sys docs)
Gomain returns → program exits, not waiting for other goroutines; defers in main run, nothing else doesRuntime: syscall.Exit → exit_groupos.Exit(n) (defers not run), log.Fatal* (always 1), unrecovered panic (2)Doc note: keep status in [0, 125]; kernel keeps 8 bits (go.md §3.3, pkg.go.dev/os)
Javamain returning ends only the main thread; JVM exits when the last non-daemon thread ends, then hooks runJVM termination → OS process exitSystem.exit(n) ≡ Runtime.exit(n) (hooks), Runtime.halt(n) (no hooks)exit(3) keeps the least significant byte — exit(300) is observed as 44; the JDK docs do not document this, it is OS behavior (java.md §3.9, man-pages exit(3))
RustTermination::report(self) -> ExitCode; Err prints Error: {err:?} and exits FAILURE (1)lang_start → single-threaded libc::exitstd::process::exit(i32) (no destructors), std::process::abort()“On most Unix-like platforms, only the eight least-significant bits are considered” — docs’ own example: exit(0x0100) is 0 on Linux, 256 on Windows (rust.md §3.3, std process::exit)
Node.jsEvent loop drains (no referenced handles left) → 'exit' listeners → exit 0Node/V8 teardownprocess.exit([code]) (sync, async I/O dropped), process.exitCode (preferred channel), process.abort()Not language-documented; OS-level 8-bit rule applies (nodejs.md §3.3–3.4, process docs)
OCamlLink-order unit initializers run, then an implicit exit 0Stdlib.exit → caml_sys_exit → C exitStdlib.exit n (hooks + flush_all), Unix._exit n (nothing runs)caml_do_exit ends in C exit(retcode) → status & 0xFF (ocaml.md §3.2/3.6, stdlib.ml/sys.c)

Asymmetries that bite.

  • Same syntax, different blast radius. return from main in C++ unwinds main’s frame (automatic objects destroyed) before std::exit; std::exit called anywhere else destroys no automatic objects at all — that is a documented standard sentence, not an implementation quirk (cpp.md §3.2, N4950 [support.start.term]p9). Go’s main return runs only main’s defers; os.Exit runs nobody’s defers (go.md §3.2–3.3, pkg.go.dev/os “deferred functions are not run”).
  • Who waits. Java is the outlier: returning from main does not terminate the process while any non-daemon thread lives (java.md §3.2, JLS §12.8); Go explicitly does not wait for goroutines (go.md §3.4, language spec); Rust does the opposite — when the main thread terminates the whole program shuts down and remaining threads’ destructors “may not be executed” (rust.md §3.10, std thread docs).
  • Truncation is documented in some chapters and absent in others. POSIX/C state status & 0377 normatively; Rust’s docs show the Linux/Windows divergence; Java’s docs are silent and java.md anchored the fact in man-pages exit(3) — treat “my exit code was 300” as a bug in five of the eight chapters’ terms (java.md §3.9).

2. Cleanup and finalization guarantees

The core comparison. Legend: ✅ runs · ❌ does not run · ⚠ partial / conditionally · — no such mechanism. Rows cite their chapter once (first column); every cell is that chapter’s verified per-path table (c.md §3.4, cpp.md §3.8, python.md §3.7, go.md §3.2, java.md §3.8, rust.md §3.10, nodejs.md §3.3/3.5, ocaml.md §3.3).

LanguagePathDestructors / defers / finallyatexit-family / hooksFinalizers / GC-anchored cleanupBuffered output flushed
Cnormal return / exit(n)— (no destructors)✅ atexit reverse order—✅ (stdio flush+close)
Cquick_exit / _Exit/_exit—❌ (at_quick_exit only / none)—❌ (_exit shall not flush)
Cabort() / signal death—❌—❌ (glibc practice)
C++return from main✅ main’s automatics✅ (statics + atexit woven LIFO)✅ static/thread-duration dtors✅
C++std::exit(v) any thread❌ automatics never✅ calling thread’s thread_local dtors first, then statics + atexit⚠ other threads keep running through teardown (UB if they touch dying objects)✅
C++quick_exit / _Exit❌❌ (at_quick_exit only / none)❌❌ / impl-defined
C++uncaught exception → terminate → abort⚠ impl-defined whether stack unwinds first (no-handler case); shall not unwind otherwise❌❌❌ (glibc)
Pythonnormal completion / uncaught exception (status 1)✅ finally/__exit__ on the way out✅ atexit LIFO, after non-daemon threads joined✅ __del__ may run at final GC (weakened globals)✅ (flush failure ⇒ status 120)
Pythonsys.exit(n)✅ (it is an exception)✅✅✅
Pythonos._exit(n)❌❌❌❌
Pythonos.abort()❌ (bypasses even the Python SIGABRT handler)❌❌❌
Gomain returns✅ main’s defers only— (no atexit mechanism)❌ finalizers not guaranteed❌ runtime does not flush bufio
Goos.Exit(n) / log.Fatal❌ any goroutine—❌❌
Gounrecovered panic (any goroutine)✅ panicking goroutine’s defers; ❌ all others—❌❌
Godefault signal death❌—❌❌
Javamain returns, last non-daemon ends✅ normal execution until shutdown begins✅ shutdown hooks, unspecified order, concurrent❌ finalize/Cleaner not guaranteed⚠ (no documented flush of Java buffers; System.out is typically line-flushed)
JavaSystem.exit(n) / signal (no -Xrs)❌ finally/TWR close at JVM termination✅ hooks (the only guaranteed app callback)❌❌
JavaRuntime.halt(n) / SIGKILL❌❌❌❌
Rustreturn from main✅ current thread’s locals; ⚠ other threads killed mid-flight✅ C atexit runs (documented current implementation)❌ (no finalizers; Drop is the mechanism)✅ BufWriter drop-flush on unwind paths
Rustprocess::exit(n)❌ no destructors, any thread⚠ C atexit + fini sections do run (documented implementation)❌❌ Rust BufWriter lost; C stdio flushed
Rustabort() / panic="abort" / signal death❌❌ (abort runs no hook; panic="abort" does run the panic hook first)❌❌
Node.jsloop drains / process.exitCode✅ pending async work completes first✅ 'exit' listeners (synchronous only) — the one “immediate” exit that still runs user callbacks❌ GC plays no role in cleanup⚠ async stdout writes may be lost on process.exit()
Node.jsprocess.exit(n)❌ pending async I/O dropped✅ 'exit' listeners run❌❌ (often truncated)
Node.jsprocess.abort() / uncaught (status 1)❌⚠ 'exit' listeners run before abort in practice — undocumented, do not rely❌❌
OCamlexit n / implicit exit 0/2✅ (OCaml has no destructors; Fun.protect ~finally is the idiom)✅ flush_all → Domain.at_exit → Stdlib.at_exit, LIFO✅ Gc.finalise at GC; ⚠ caml_shutdown only with OCAMLRUNPARAM=c✅ flush before hooks (unlike C/C++/Python)
OCamluncaught exception (exit 2)—✅ do_at_exit wrapped in try…with _ -> () — raising hooks silently swallowed✅✅
OCamlUnix._exit / default signal death❌❌❌❌

Asymmetries that bite.

  • exit() is a family of five different contracts, not one. C/C++ run atexit on exit only; Rust’s process::exit still runs the C atexit and ELF fini sections (documented implementation) while running zero Rust destructors — so a C library’s cleanup fires but your Drop/BufWriter does not (rust.md §3.3, std process::exit). Go’s os.Exit runs nothing at all; Python’s atexit runs; Java’s hooks run but its finalizers never do; Node’s 'exit' listeners run but must be synchronous; OCaml flushes first and then runs two hook chains. Five runtimes, five answers to “what does exit mean”.
  • Hook-order inversion. OCaml flushes stdio before running hooks (ocaml.md §3.2, stdlib.mli); C/C++ flush after the atexit list (c.md §3.1, man7 exit(3)); Python flushes after atexit and module teardown (python.md §3.7, pylifecycle.c). A logging hook that prints relies on different guarantees in each runtime.
  • Handler exceptions are policy, not accidents. C++: an atexit handler that throws calls std::terminate (cpp.md §3.3); Python: handler exceptions are accumulated and the last one re-raised after all handlers ran, changing the final status to 1 (python.md §3.10, atexit docs); OCaml: swallowed in the uncaught path, fatal in the normal exit path (ocaml.md §3.3, printexc.ml); C: no policy — a handler can exit recursively (UB-adjacent) or return to the list.
  • exec wipes the board in C-family children. After a successful exec no atexit/at_quick_exit/pthread_atfork registration survives (c.md §3.4/5.2, POSIX exec) — which is exactly why the fork-child error path must be _exit(127), a rule every chapter in this part restates for its own substrate (cpp.md §5.6, python.md §3.5, ocaml.md §5.2).

3. Exceptions, panics, fatal errors, and abort behavior

Default failure statuses, and whether each is a documented guarantee or implementation behavior (python.md §3.4, go.md §3.6, rust.md §3.1/3.10, ocaml.md §3.4, java.md §3.6, nodejs.md §3.2/3.6, cpp.md §3.6):

LanguageTriggerDefault statusGuarantee levelSource behind the number
Pythonuncaught exception (non-SystemExit)1documented ([language]/[CPython])docs.python.org sys/exceptions; traceback to stderr
Pythonflush error during finalization120documented + sourcesys docs + pylifecycle.c
Gounrecovered panic in any goroutine2documented ([std lib documented])pkg.go.dev/runtime (GOTRACEBACK); process exits cleanly, not 128+N
Rustmain-thread panic (unwind)101implementation-anchoredstd rt.rs “map escaping main-thread panic to 101”; labeled “not a stability contract”
Rustmain returns Err1documentedTermination → ExitCode::FAILURE
OCamluncaught exception2documentedstdlib.mli “implicit exit 2”; printexc.c exit(2) verified
OCamlUnix.handle_unix_error2documentedunix.mli
Javauncaught exception in main (last non-daemon)1implementation behaviorJLS/Javadoc silent; HotSpot launcher observed 1 (sibling chapter table)
Node.jsuncaught fatal exception1documentedv24 exit-codes table
Node.jsV8 fatal error / bootstrap failure5 / 3, 4, 10 / 9 / 13 / 14documentedv24 exit-codes table
C++uncaught exception → terminate → default handler → abortSIGABRT death (shell 134 = 128+SIGABRT on Linux)mechanism standard; status is a platform observation, not a standard numberN4950 [except.terminate]p2; default handler calls abort()
Cassert failure / abort()SIGABRT deathstandard mechanismISO C §7.2.1.1 / §7.22.4.1
Gono abort equivalent — closest is GOTRACEBACK=crash runtime failure—documented knobpkg.go.dev/runtime (go.md §3.8)

Numeric collisions — same byte, different contract. Exit code 2 is a Go panic (documented runtime failure path), an OCaml uncaught exception (documented stdlib.mli), and — per Node’s own table — unused, “reserved by Bash for builtin misuse” (nodejs.md §3.2). Exit code 1 is “uncaught exception” in Python and Node (documented), FAILURE in Rust (documented), log.Fatal in Go (documented, hardcoded), and merely a launcher convention in Java (implementation). Code 101 is only Rust’s panic. A monitoring rule like “nonzero ⇒ error” survives; any rule keyed to a specific value does not, unless you have checked which chapter documents it.

Abort behavior. C’s abort() unblocks SIGABRT, raises it, and re-raises past a returning handler (glibc; man7 abort(3)); C++ std::terminate bottoms out in the same abort(); Python’s os.abort() explicitly bypasses the Python-level SIGABRT handler and the whole shutdown sequence; Rust’s process::abort() skips even the panic hook, while panic="abort" still runs the hook first (documented asymmetry, rust.md §3.5); Node’s process.abort() is unavailable in Worker threads; OCaml has no OCaml-level abort — an abort() variant exists only behind the runtime-internal caml_abort_on_uncaught_exn knob (ocaml.md §3.4); Java’s moral equivalent is Runtime.halt — no hooks, no sequence, straight to the OS (java.md §3.4, Runtime docs).

4. Signal registration and handling

Eight models, none equivalent (c.md §4.1–4.7, POSIX.1-2024 sigaction/sigwait; cpp.md §4.1–4.4, N4950 [support.signal]; python.md §4.1–4.7, signal docs + PEP 475; go.md §4.1–4.5, os/signal docs; java.md §4.1–4.3, java manual; rust.md §4.1–4.5, std docs + std source; nodejs.md §4.1–4.6, process docs; ocaml.md §4.1–4.4, sys.mli/unix.mli/intfc manual).

LanguageRegistration modelHandler runs…Who changes default dispositions
CISO signal() (thin, impl-defined rearm) + POSIX sigaction (masks, SA_*, queuing); sigwait thread as the handler-free patternin the kernel interruption frame, on any thread not blocking the signal; async-signal-safe list onlyOnly the program (and libraries — query with sigaction(sig, NULL, &old)); glibc’s signal() = BSD semantics over sigaction(2)
C++<csignal> = C’s header verbatim; no C++ mask/queue/sigwait API; POSIX layer used from C++same frame as C; the C++ signal-safe list additionally forbids dynamic_cast, throw, entering a try block, and thread_local accessOnly the program (C substrate)
Pythonsignal.signal() → C handler sets a flag; Python handler runs later at a bytecode boundaryin the main thread only, between bytecodes; registration off the main thread raises ValueErrorStartup: SIGPIPE → SIG_IGN, SIGINT → KeyboardInterrupt; persists BSD-style except SIGCHLD; set_wakeup_fd bridges to loops
Gosignal.Notify(ch, sigs…) — delivery to a channel; NotifyContext (1.16)on a runtime-spawned goroutine — Go code never runs inside a signal handler; no async-signal-safety rules for Go codeThe runtime, at startup: sync signals → runtime panics; SIGHUP/SIGINT/SIGTERM exit; SIGQUIT dumps; SIGPROF profiling; SIGURG preemption; Notify re-arms the listed set; Reset/Ignore/Stop restore
JavaNo public API. Shutdown hooks only; sun.misc.Signal is internal (jdk.unsupported), rejects VM-used signals, dispatches handlers on a fresh MAX_PRIORITY threadnever in the signal frame; hooks run concurrently as threads during the shutdown sequenceThe JVM: installs handlers for SIGHUP/SIGINT/SIGTERM (hooks) and SIGQUIT (thread dump); -Xrs stops all of it — masks untouched, no handlers, user code must call System.exit
Ruststd has none — no registration, masking, or handler API; FFI (libc crate) or third-party (signal-hook, ctrlc, tokio::signal), none covered by Rust-project compatibilitywhatever thread the OS picks (any non-blocker); masks inherited by std::thread::spawn children (OS inheritance, not a std guarantee)The runtime changes exactly one disposition: SIGPIPE is set to SIG_IGN at startup (plus fd 0/1/2 sanitization); everything else is untouched
Node.jsprocess.on('SIGINT', …) — event listeners; process.kill(pid, sig) sends to any pidas ordinary JS on the event loop, later; a blocked loop (e.g. spawnSync) delays handling indefinitelyNode/libuv: SIGPIPE ignored by default; SIGTERM/SIGINT default handlers reset terminal mode and exit 128+N — installing a listener removes that default (Node stops exiting)
OCamlSys.signal / Sys.set_signal (Signal_default/_ignore/_handle); no Unix.sigaction/Unix.sys_signal (verified-absent); Unix.sigwait (5.4), Thread.wait_signalat safe points — the kernel frame only sets a flag; caml_process_pending_actions runs handlers/finalisers/GC; allocation-free loops delay delivery (pump with Sys.poll_actions, 5.3)The runtime: signal setup at startup; native SIGSEGV/SIGTRAP/SIGFPE handlers installed at caml_startup, reset at caml_shutdown (documented FFI constraint); Sys.catch_break turns Ctrl-C into Sys.Break

Asymmetries that bite.

  • Three architectures, one goal. C/C++/Rust-FFI run handler code inside the kernel frame and pay the async-signal-safety tax (C++ pays more: no try-blocks, no dynamic_cast, no thread_local). Python, OCaml, Go, and Node all defer to a safe context (flag-then-bytecodes, safe points, runtime goroutine, event loop) and are async-signal-safe by construction — the cost is delivery latency and, in Python’s case, main-thread serialization. Java splits the difference: hooks are normal threads, but you cannot learn which signal fired (java.md §4.1, java manual).
  • Drop-when-full is documented exactly once. Go’s Notify channel “will not block sending” — an unbuffered or full channel silently drops signals; the docs specify buffer size 1 per signal as sufficient (go.md §4.2, os/signal doc). Python’s set_wakeup_fd documents the mirror-image hazard (full pipe ⇒ lost signals, warn_on_full_buffer); Node listeners cannot drop (event loop is the queue); C/OCaml standard signals collapse while pending (one delivery), which is POSIX, not language.
  • Dispositions leak into children selectively. Python restores SIG_IGN→SIG_DFL for SIGPIPE (et al.) in subprocess children by default (restore_signals=True); Go children inherit the runtime’s modified signal mask (documented); POSIX exec resets caught → default but keeps ignored → ignored (c.md §4.5) — three different “what does my child see” answers (python.md §5.5, go.md §4.4).

5. Subprocess creation, waiting, cancellation, and termination

API-model table (c.md §5, cpp.md §5, python.md §5, go.md §5, java.md §5, rust.md §5, nodejs.md §5, ocaml.md §5):

LanguageCreation surfaceShell by default?Timeout built-inAuto-kill on timeoutKill / destroy ladder
Cfork+exec*, posix_spawn(file_actions, attrs), system, popenonly system/popen (sh -c --)none—manual: alarm+SIGALRM, WNOHANG polling, sigtimedwait/signalfd [Linux]; kill, then reap
C++C substrate (std::system impl-defined; POSIX fixed: returns the wait status word), popen/pcloseonly system/popennone—manual: WNOHANG poll → SIGTERM → grace → SIGKILL → reap (cpp.md §5.9)
Pythonsubprocess.run/Popen; os.posix_spawn (3.8+); asyncio variantsshell=False default (injection defense is structural); shell=True = /bin/sh -c✅ run(timeout=), Popen.wait(timeout=), communicate(timeout=)run kills + waits, then raises TimeoutExpired; bare wait/communicate raise without killing (python.md §5.10/5.7)terminate() SIGTERM → kill() SIGKILL; send_signal; _active list opportunistically reaps abandoned children
Goexec.Command/CommandContext — no shell, ever (shell is opt-in as an explicit argv)never✅ context deadline + Cancel/WaitDelay (Go 1.20)context done → Cancel (default Process.Kill = SIGKILL); WaitDelay bounds stragglers: kills, closes pipes, returns ErrWaitDelay (go.md §5.5, os/exec docs)Process.Signal(SIGTERM) → Kill (SIGKILL); Wait required even after Kill (zombie); manual goroutine+select pattern pre-1.20
JavaProcessBuilder argv array (+ startPipeline); Runtime.exec legacynever (shell only if you ask for sh -c)✅ waitFor(timeout, unit) / waitFor(Duration) (@since 24)NO — timeout returns false and kills nothing; the kill ladder is documented user code (java.md §5.3)destroy() → SIGTERM, destroyForcibly() → SIGKILL (source-verified ProcessHandleImpl_unix.c; pid-reuse guarded by start time); onExit() (since 9) for async supervision
Ruststd::process::Command builder — never a shell; CommandExt::exec (replace self)nevernone in std — documented gap—Child::kill() = SIGKILL directly (no SIGTERM step in std); Drop neither kills nor reaps (documented); poll try_wait + kill pattern (rust.md §5.4/5.9)
Node.jsspawn (no shell), exec (always a shell, buffered, 1 MiB maxBuffer), execFile, fork (+IPC); sync variants block the loopexec/execSync only✅ timeout option (v15.13.0/v14.18.0), signal: AbortSignal (v15.5.0)kills with killSignal (default SIGTERM) — exactly one signal, no SIGKILL escalation; manual grace-timer ladder for the stubborn (nodejs.md §5.10)subprocess.kill([signal]) default SIGTERM; killed ≠ terminated; libuv reaps after 'exit'
OCamlUnix.system/Sys.command (shell string), create_process[_env] (argv + fd redirects), open_process* familysystem/Sys.command/open_process* string formsnone (no wait4/waitid, no timed wait — verified-absent)—Unix.kill pid SIGTERM → SIGKILL; timeout via Unix.alarm + handler, waitpid [WNOHANG] polling, or Unix.select on pipes (ocaml.md §5.11)

Asymmetries that bite.

  • Four auto-kill semantics under the same word “timeout”. Python run() kills and reaps before raising (exception carries partial output); Node kills with one configurable signal and reports signal-death in 'exit'; Go kills via Cancel and — uniquely — also bounds pipe draining with WaitDelay (a child that exits but leaves pipes open is still cleaned up); Java kills nothing — waitFor(timeout) returning false is a poll result, and the SIGTERM-then-SIGKILL ladder is explicitly your code (java.md §5.3, Process docs). C, C++, Rust, and OCaml offer no timeout at all; their chapters document the same composition by hand.
  • Injection posture is a default, not a feature. Python (shell=False), Go (Command), Rust (Command), Java (ProcessBuilder), Node (spawn/ execFile), and OCaml (create_process, open_process_args*) pass argv verbatim with no shell layer; the shell-string APIs (system, popen, Python shell=True, Node exec, OCaml Unix.system) all carry the same documented injection warning, with language-specific mitigations: shlex.quote/join (not a security boundary, per python.md), Filename.quote_command (OCaml, documented), or passing user data as a positional shell parameter (sh -c 'grep -e "$1"' sh "$user", go.md §5.1).
  • Exec-failure reporting splits two ways. Python raises FileNotFoundError in the parent (unless the posix_spawn fast path was taken — 3.8 versionchanged); Java throws typed IOException; Node emits 'error' (ENOENT) with 'exit' possibly never firing; C-family and OCaml children must self-report — conventionally _exit(127), though OCaml’s raw exec failure actually raises Unix_error inside the child (fatal → exit 2) unless you wrap it (ocaml.md §5.2, execvp.c verified); Go reports exec.ErrNotFound from Start.
  • One-Wait contracts differ. Go: exactly one Wait, mandatory, and GODEBUG=execwait=2 detects leaks via panic-on-finalizer; Rust: no implicit reap ever (zombie warning in the docs); Node: reaping is implicit after 'exit' (libuv); Python: Popen.__del__ + module-level _active reaper as a GC-anchored net; Java: waitFor/onExit reap; C/OCaml: every child needs exactly one waitpid/close_process* or a SIGCHLD reaper.

6. Child-process status types and signal interpretation

Representation table (c.md §3.5/5.3, cpp.md §5.1, python.md §5.8, go.md §5.6, java.md §5.7, rust.md §5.3/5.6, nodejs.md §5.12, ocaml.md §5.3):

LanguageType a parent holdsSignal-death representationLossy?
C / C++raw int wait-status worddecode with WIFSIGNALED/WTERMSIG/WCOREDUMP (the last newly standardized in POSIX.1-2024)No — the full kernel word, macros standardized
PythonPopen.returncode: intnegative -N means “terminated by signal N” (POSIX only, documented); raw status via os.waitpid + os.waitstatus_to_exitcode-N loses core-dump/stopped bits; raw status keeps them
Go*os.ProcessStateExitCode() returns −1 as a sentinel when “the process hasn’t exited or was terminated by a signal”; full decode via Sys().(syscall.WaitStatus); String() forms (“signal: killed”) verified in os sourceExitCode() alone is unusable for signals; WaitStatus is lossless
Javaint from waitFor/exitValue128+N — an implementation convention from jdk-25+36 source (WTERMSIG_RETURN(status) = WTERMSIG(status) + 0x80, “what all Unix shells do”); no JDK documentation defines it (ledger PL-JV-073, source-limited)Yes: exit(143) by the child is indistinguishable from SIGTERM death; no core/stopped info at all
RustExitStatus (the wait status on Unix)code() → None + ExitStatusExt::signal() → Some(N), plus core_dumped/stopped_signal/continuedNo — near-lossless; docs note 128+N is shell behavior, not OS
Node.js'exit'/'close' (code, signal) — exactly one non-null; spawnSync {status, signal} (status: null on signal); util.convertProcessSignalToExitCode (v24.14.0) maps the conventionsignal as the string name ('SIGTERM')Loses core-dump bit; the code/signal exclusivity is documented and enforced
OCamlprocess_status ADT: WEXITED of int | WSIGNALED of Sys.signal | WSTOPPED of Sys.signal (signal payloads typed since 5.4)the constructor is the signal attributionLoses WCOREDUMP and WIFCONTINUED (no constructors for them)

Asymmetries that bite — and the attribution method.

  • Three incompatible encodings of the same event. A SIGKILLed child is returncode = -9 for a Python parent, ExitCode() == -1 (check WaitStatus) for a Go parent, exitValue() == 137 for a Java parent, code == null, signal == 'SIGKILL' for a Node parent, signal() == Some(9) for a Rust parent, WSIGNALED Sys.sigkill for OCaml. Portable libraries must normalize, and “137” is never a kernel fact — it is the shell’s 128+N arithmetic applied after decoding (c.md §3.5, rust.md §5.3).
  • Attribution needs the right evidence tier. Decodable-in-process: C/C++ macros, Rust ExitStatusExt, OCaml ADT, Go WaitStatus. Convention-tier (needs corroboration): Java’s 128+N (source-verified, undocumented), Node’s signal name (documented, but no core-dump bit), Python’s -N (documented, POSIX-only). When a wrapper (sh -c) sits in between, only the wrapper’s status is visible — Rust’s docs spell out the two-case attribution (code()==Some(143) ⇒ inner command died of SIGTERM via the shell’s encoding; signal()==Some(15) ⇒ the shell itself died), rust.md §5.6. The evidence-first method (raw wait-status → waitid/siginfo_t → /proc/strace when the word is gone) is the decision tree; the per-value folklore lives in common-status-values.md.
  • KeyboardInterrupt is a Python-only trap: an uncaught KeyboardInterrupt exits 1 with a traceback because Python raised it — the 128+2 shell view happens only when the process actually dies from SIGINT (python.md §3.4); every other chapter here dies by signal on Ctrl-C unless it catches it.

7. Pipe and stream behavior

Deadlock exposure and fd defaults (c.md §5.6/6.4, cpp.md §6.8–6.9, python.md §5.6–5.7/6.6, go.md §5.4, java.md §5.2, rust.md §5.5, nodejs.md §6.4, ocaml.md §5.7/5.10):

LanguageSafe-drain construct (built-in)Naive wait-before-drain deadlocks?Parent-side bufferingfd-inheritance default
C / C++none — hand-rolled poll/select loops; popen is one-pipe onlyyes (any two-pipe setup); fork copies unflushed stdio → double outputstdio full-buffered on pipes; C++ cout filebuf shares C stream by defaultinherit everything without FD_CLOEXEC — O_CLOEXEC/pipe2/close_range are opt-in hygiene
PythonPopen.communicate() (drains both pipes concurrently, then waits)yes — the docs’ canonical deadlock warning on wait() with stdout=PIPEbufsize=-1 default on the parent pipe file objectsnon-inheritable by default (PEP 446); close_fds=True since 3.2; pass_fds allowlist
Gogive Cmd a writer (cmd.Stdout = &buf) + Run(); or drain StdoutPipe concurrentlyyes — Wait blocks on the I/O goroutine that can’t finishcopying goroutine + your writer’s bufferingnil Stdin/Stdout/Stderr → /dev/null; ExtraFiles → fd 3+i; no blanket CLOEXEC story — you own fds
Javadrain all streams concurrently (threads / CompletableFuture); redirectErrorStream(true); Redirect.to(file)/DISCARD/INHERITyes — the Process Javadoc caution; worked two-pipe deadlock in java.mdnone on Process streams (raw pipes)Redirect.PIPE default; Redirect.INHERIT opt-in per stream
Rustoutput()/wait_with_output() (drain both, then reap); wait() closes stdin first (EOF signaling)yes — std’s own Stdio warning on hand-rolled feed/drainnone (Output is raw bytes)spawn/status inherit stdio, output pipes — defaults differ per method
Node.jsdrain via 'data'/streams, or exec/execFile buffered capture (removes the deadlock, adds 1 MiB maxBuffer truncation)yes — one undrained pipe stalls the childparent-side Node streams; pipe writes asynchronous (vs TTY sync) — the process.exit truncation trapstdio 'pipe' default; 'inherit'/numeric fds opt-in; 'ipc' channel unique to Node
OCamlopen_process* + read-to-EOF before close_process*; Unix.select pump for three-channel I/Oyes — the documented open_process_full deadlock (write stdin while stdout fills)OCaml out_channel buffered — “be careful to call Stdlib.flush” (documented warning)not close-on-exec by default — ?cloexec:true or set_close_on_exec opt-in, emphatically recommended

Asymmetries that bite.

  • The 64 KiB cliff is universal; the escape hatch is not. Every chapter documents the same kernel fact (Linux default pipe capacity, per pipe(7)/pipe(2)) and the same two-party deadlock; only Python (communicate), Go (writer on Cmd), Rust (wait_with_output), Java (concurrent drain + redirect modes), and Node (buffering APIs) ship a library-level fix. C, C++, and OCaml give you the parts, not the pattern.
  • Who flushes your data. OCaml’s exit flushes channels first (§2); C/C++ flush stdio inside exit after atexit; Python flushes sys.stdout/stderr last, and a failure there silently rewrites your status to 120; Rust’s BufWriter flush is a drop side effect — so it runs on unwind/return but not on process::exit/abort; Go’s runtime never flushes bufio on any path; Node’s async pipe writes can be severed by process.exit(). Same bug report, five root causes.
  • Inheritance philosophy splits three ways. Python flips POSIX’s default (non-inheritable fds + close_fds=True); C/C++/OCaml keep the POSIX leak default with opt-in CLOEXEC hygiene; Go, Java, Rust, and Node replace the question with per-stream APIs (ExtraFiles fd 3+i, Redirect, Stdio, stdio arrays) — in which fds beyond 0–2 are always an explicit act. Fork-copying of unflushed stdio buffers (the double-output bug) is a C/C++ special: Python/Go/Rust/Node children don’t inherit parent stdio buffers because the child execs a new image or never runs parent-language cleanup.

8. Threading and runtime restrictions

Signal-handler thread restrictions × what happens to other threads on exit (c.md §4.7/6.5, cpp.md §3.7/6.1–6.2, python.md §4.3/6.5, go.md §3.6/4.1, java.md §3.3–3.6, rust.md §3.10/4.4, nodejs.md §3.9/4.6, ocaml.md §4.4/6.1):

LanguageHandler thread restrictionOther threads at process exit
CPOSIX: process-directed signals to any thread not blocking; signal() in a threaded program is UB per ISO C; sigwait-thread is the clean patternexit from any thread is legal; atexit runs on the exiting thread while others still run (concurrent atexit racing exit is unspecified; touching dying objects is UB); last-thread-death ⇒ status 0
C++inherits C’s model; handler may not touch thread_local (§4 list); sigwait-thread via std::thread is the idiomatic replacementstd::exit from any thread; a joinable std::thread destroyed during main’s unwind calls std::terminate — “exit while workers run” can become abnormal termination by object layout; join-then-exit is the documented fix
Pythonmain thread only, at bytecode boundaries (GIL-dependent; build-dependent in the free-threaded build); handler exceptions land “out of thin air” in mainthreading._shutdown joins all non-daemon threads (a hung one hangs exit); daemon threads killed abruptly — no finally, no __exit__, no __del__ (documented)
Gonone for Go code — runtime multiplexes onto a goroutine; cgo handlers need SA_ONSTACK (documented interop contract)main return doesn’t wait; an unrecovered panic in any goroutine kills the process (exit 2); os.Exit from any goroutine is a design decision (usually one place)
Javahooks are ordinary threads (concurrent, unspecified order, coded defensively per the API Note); sun.misc.Signal dispatches on a fresh max-priority threadnon-daemon threads keep the JVM alive — main returning is not exit; at JVM termination all threads are frozen mid-instruction: no finally, no uncaught handlers, no TWR close (documented); daemons get no grace; virtual threads are daemon by construction
Rustno std API; with FFI the handler lands on any non-blocker thread; std::thread::spawn children inherit the pthread mask (OS behavior)main-thread termination ends the program, and remaining threads’ destructors “may not be executed” (documented); a spawned-thread panic ends only that thread (join() → Err)
Node.jsno JS handler runs in the signal frame (libuv converts to an event); a blocked loop defers signal handling indefinitely (sync APIs, spawnSync)one JS thread + libuv threadpool; Workers have no signals and no process.abort(); a Worker dying never ends the process (its own 'exit' event does not propagate); cluster workers are separate processes
OCamlhandlers deferred to safe points; POSIX thread model for systhreads (self-directed to the sender, external to one non-blocker); Sys.Break “will arise in any one of the active threads” and keeps arising (documented); which domain receives an external signal is source-limitedno Domain.kill/Thread.kill (verified-absent); Domain.join also joins that domain’s systhreads; Unix.fork fails if any domain has been spawned and is documented hazardous with threads (the multicore rule)

Asymmetries that bite.

  • The “who owns shutdown” question has four answers. Java: non-daemon threads own it (they can veto exit indefinitely). Python: the main thread owns it (non-daemon join first, daemons slaughtered). Rust: the main thread owns it absolutely (its return kills the rest). Go: nobody owns it — any goroutine’s panic is process-fatal, and only os.Exit/main-return end the show. Writing “portable” supervision code means picking the most restrictive model (Java’s) and adapting down.
  • Signal handling under load. Python’s and OCaml’s deferred models degrade the same way: a CPU-bound loop without bytecode boundaries (Python) or allocation points (OCaml) delays delivery unboundedly — Python has no pump short of reaching a bytecode; OCaml documents Sys.poll_actions (5.3) as the manual pump (ocaml.md §4.1, sys.mli). Go and Node defer to schedulers that always run eventually. C/C++/Rust-FFI deliver instantly, into the least safe context of the eight.

9. Portability and version dependencies

Windows deltas (brief; each row cites the chapter that verified it).

LanguageWindows divergence (verified form)Chapter
Pythonsignal.signal limited to 7 signals; os.kill maps non-CTRL signals to TerminateProcess; kill() is an alias of terminate() on Popenpython.md §4.8/5.8
Goonly os.Interrupt exists; Process.Signal(os.Interrupt) “is not implemented”; no ExtraFiles; SysProcAttr Linux fields absent; Command quoting via CommandLineToArgvW with cmd.exe/batch exceptionsgo.md §4.9/5.12
Javaconsole control events (CTRL_C/CLOSE/LOGOFF/SHUTDOWN) drive hooks; CTRL_BREAK → thread dumps; -Xrs installs no console handler; no SIGTERM — destroy() is terminate-style (check supportsNormalTermination())java.md §4.5/5.9
Rustexit codes not truncated (process::exit(0x0100) is 256); cmd.exe/.bat argument decoding “vulnerable to malicious input” — raw_arg escape hatch; executable resolution order differsrust.md §3.3/5.10
Node.jsno signals: SIGINT/SIGTERM/SIGKILL via kill() emulate unconditional termination; SIGBREAK (Ctrl+Break); SIGHUP on console close with ~10 s hard kill; group-kill throwsnodejs.md §4.2/4.7
OCamlsignal mask primitives raise Invalid_argument; Unix.kill emulates only sigkill → child exits with code 1067 (changed in 5.5 — previously 0); Unix.fork raises Invalid_argumentocaml.md §4.3/5.4/5.1
C / C++chapters are Linux/POSIX-scoped; C23/C++23 wording is platform-neutral but the process layer cited (fork/exec/wait/sigaction) is the POSIX substratec.md, cpp.md

Version-gated features (each verified by the named chapter; the entry point for the underlying official source is that chapter’s References).

FeatureSinceVerified by
POSIX _Fork (AS-safe fork), FD_CLOFORK, WCOREDUMP standardizationPOSIX.1-2024 (Issue 8)c.md §5.1/5.9/5.3
close_range(2)Linux 5.9c.md §5.9
C23 [[noreturn]], library renumbering (7.22→7.24)C23 (N3220)c.md §1.5
Python: PEP 446 non-inheritable fds / PEP 475 EINTR retry3.4 / 3.5python.md §5.3/4.4
Python Popen(process_group=)3.11python.md §1.10/5.4
Python pidfd-based wait(timeout) on Linux ≥ 5.33.15python.md §1.10/5.6
os.fork() DeprecationWarning in threaded processes3.12python.md §1.10/6.2
Go signal.NotifyContext / exec.ErrDot / Cmd.Cancel+WaitDelay+ErrWaitDelay1.16 / 1.19 / 1.20go.md §1.8/4.3/5.5
Go GODEBUG=execwait=2 leak detection1.20go.md §5.3
Java Process.onExit, ProcessHandle, Redirect.DISCARD9java.md §1.11/5.5
Java finalization deprecated for removal (JEP 421; finalize still present in 25)18java.md §1.11/1.8
Java waitFor(Duration) / no-pin synchronized (JEP 491) / flexible main (JEP 512)24 / 24 / 25java.md §1.11
Rust Termination/ExitCode, ExitCode: Default1.61 / 1.75rust.md §1.8/3.2/3.4
Rust CommandExt::process_group1.64rust.md §1.8/5.7
Rust ExitStatusExt::{core_dumped, stopped_signal, continued}1.58rust.md §5.3
Node timeout option / AbortSignal / abort-specific killSignalv15.13.0/v14.18.0 / v15.5.0 / v15.11.0nodejs.md §5.2/5.10
Node util.convertProcessSignalToExitCode / DEP0190 (args + shell:true)v24.14.0 / v23.11.0/v22.15.0nodejs.md §5.12/1.10
OCaml safe-point pump Sys.poll_actions / typed Sys.signal + Unix.sigwait + sigio/sigwinch5.3 / 5.4ocaml.md §1.11/4.3
OCaml Unix.unsetenv; Unix.kill Windows → 1067; Mutex/Condition/Semaphore promoted to Stdlib5.5ocaml.md §1.11

Recorded discrepancies between chapters

Per the grounding rule, conflicts are stated rather than resolved:

  1. Rust chapter vs. its own sibling. rust.md explicitly corrects the older exit/signal-scoped Rust chapter on three items: CommandExt::setsid and chroot (and groups) are nightly-only on the current build (the sibling listed them as available); before_exec is deprecated-since- 1.37 in favor of unsafe pre_exec; and process::exit runs C atexit/ fini per the current documented implementation. Where the two disagree, rust.md (std 1.99.0) is authoritative for this part.
  2. Java signal-death status is undocumented by design (ledger PL-JV-073, source-limited): the 128+N value comes from jdk-25+36 source, while Python/Node/Rust document their own encodings. This comparison labels the Java row implementation-tier, not because the behavior is secret, but because the specification is silent.
  3. OCaml native SIGSEGV→Stack_overflow conversion is source-limited in 5.5.1 (fail_nat.c references a handler signals_nat.c no longer contains — ledger PL-OC-035); ocaml.md and this chapter claim only the C-level consequence (uncaught ⇒ exit 2).
  4. glibc manual unreachable for both C-layer chapters (c.md, cpp.md): glibc-behavior claims are anchored in man-pages 6.19 pages instead, and ledger-c marks the affected rows. cpp.md additionally substituted N4950 for cppreference (blocked) — a stronger source, noted for transparency.

References

Primary-source sets as declared by each chapter (all retrieved 2026-10; the per-claim anchors are in the chapters themselves):