---
phase: 35-psyche-sync-cross-machine-context-backup-via-private-gh-repo
plan: 03
subsystem: infra
tags: [rust, libc, unix, process, fork, setsid, detach, cross-platform]

# Dependency graph
requires: []
provides:
  - "pub fn spawn_detached_unix(exe: &Path, args: &[&str]) -> Result<u32, ()> (#[cfg(unix)] real impl)"
  - "pub fn spawn_detached_unix(...) -> Err(()) stub (#[cfg(not(unix))] cross-compile parity)"
affects: [35-05, hook_prompt.rs, dispatch_async_sync_pull]

# Tech tracking
tech-stack:
  added: []
  patterns:
    - "Unix single-fork + setsid + dup2(/dev/null) + execv detached spawn (raw libc, no std::process::Command wrapper)"
    - "argv built via CString (interior-nul rejection doubles as argv-smuggling guard)"
    - "child _exit(127) on execv failure — no Rust destructor unwind in a forked child"
    - "#[cfg(unix)] real impl + #[cfg(not(unix))] Err-stub for symmetric #[cfg]-split call sites"

key-files:
  created: []
  modified:
    - src/common/process.rs

key-decisions:
  - "Single-fork+setsid (not double-fork): claude-code hook is short-lived, so any zombie is reaped by init shortly after; keeps returned pid the actual child"
  - "envs parameter DROPPED vs Windows sibling — Plan 35-05 #[cfg]-splits the call site (RESEARCH Open Q §1); child inherits parent env (own owl binary, not untrusted)"
  - "Test 2 (bad path) asserts 'no live child remains' rather than strict Err(()) — single-fork+execv returns Ok(pid) at parent, child _exit(127); the fire-and-forget guarantee is what matters"

patterns-established:
  - "Raw-libc detached spawn helper mirroring a Windows CreateProcessW helper minus its env param"

requirements-completed: [SYNC-HOOK-01]

# Metrics
duration: 6min
completed: 2026-05-26
---

# Phase 35 Plan 03: Unix Detached-Spawn Helper Summary

**Adds `spawn_detached_unix(exe, args) -> Result<u32, ()>` to `src/common/process.rs` — a Unix-only fork+setsid+dup2(/dev/null)+execv mirror of `win_spawn::spawn_detached_no_inherit` (minus the envs param), plus a `#[cfg(not(unix))]` Err-stub for cross-compile parity, with 4 `#[cfg(unix)]` tests.**

## Performance

- **Duration:** ~6 min
- **Started:** 2026-05-26T23:31:00Z
- **Completed:** 2026-05-26T23:37:00Z
- **Tasks:** 1 (TDD: RED + GREEN)
- **Files modified:** 1

## Exact Signature Added

```rust
#[cfg(unix)]
pub fn spawn_detached_unix(exe: &Path, args: &[&str]) -> Result<u32, ()>
```

Plus the cross-compile parity stub:

```rust
#[cfg(not(unix))]
#[allow(dead_code)]
pub fn spawn_detached_unix(_exe: &std::path::Path, _args: &[&str]) -> Result<u32, ()> {
    Err(())
}
```

This matches the Windows helper `spawn_detached_no_inherit(exe, args, envs)` minus the `envs: &[(String, String)]` parameter, per the researcher decision (RESEARCH Open Q §1). Plan 35-05 `#[cfg]`-splits its call site: `spawn_detached_no_inherit(exe, args, &[])` on Windows, `spawn_detached_unix(exe, args)` on Unix.

## Fork-Strategy Choice: single-fork + setsid

**Chosen: single-fork + `setsid`.** The forked child calls `setsid()` to become a session leader detached from the controlling terminal, dup2's fd 0/1/2 to `/dev/null`, then `execv`'s the target.

**Rationale (vs double-fork):** the claude-code hook process that calls this is short-lived (runs and exits within seconds). If the detached child also exits quickly it lingers as a zombie only until the hook process exits, after which it reparents to and is reaped by init (pid 1). Skipping the second fork keeps the helper simple and makes the returned pid meaningful — it is the actual child, not an intermediate that immediately exits. This tradeoff is documented in the function's doc-comment.

## Implementation Detail

- argv is built from `CString`s (`exe` as `argv[0]`, then each arg, then a NULL terminator). Any interior NUL byte fails `CString::new` → whole spawn rejected with `Err(())`. This doubles as the T-35-03-01 argv-smuggling mitigation (no shell — direct `execv`, not `system`).
- `libc::fork()`: `-1` → `Err(())`; child branch (`0`) calls `setsid` → `open("/dev/null", O_RDWR)` → `dup2` x3 → `close` if `fd > 2` → `execv` → `_exit(127)` on execv-return (no destructor unwind); parent branch returns `Ok(pid as u32)`.
- Imports added inside the fn body (gated by the `#[cfg(unix)]` on the fn): `std::ffi::CString`, `std::os::unix::ffi::OsStrExt`. `libc` resolves via extern-prelude (already a `target.'cfg(unix)'.dependencies` entry in Cargo.toml).

## Task Commits

TDD cycle on a single task (no separate refactor commit — implementation was clean):

1. **RED — failing tests for spawn_detached_unix** - `e7ab913` (test)
2. **GREEN — implement spawn_detached_unix (fork+setsid+dup2+execv)** - `527cff1` (feat)

**Plan metadata:** committed separately with this SUMMARY.

## Files Created/Modified
- `src/common/process.rs` — appended (after `setup_ctrlc_handler`): the `#[cfg(unix)]` real impl, the `#[cfg(not(unix))]` Err-stub, and a `#[cfg(unix)] #[cfg(test)] mod tests_unix_spawn` block with 4 tests.

## Test Count: 4 (Unix-only)
- `valid_exe_returns_nonzero_pid` — `/bin/true` → `Ok(nonzero pid)`.
- `nonexistent_path_does_not_leave_live_child` — `/nonexistent/binary` → either `Err(())` or an `Ok(pid)` whose child is dead within 200 ms (no live detached process leaks).
- `child_is_detached_session_leader_or_reparented` — `/bin/sleep 5`; passes if child's session id diverges from ours OR ppid == 1; cleans up via `SIGTERM`.
- `signature_is_pinned` — `let _: fn(&Path, &[&str]) -> Result<u32, ()> = spawn_detached_unix;` pins the signature against drift.

## Detachment Test Verification Method (CI runner note)

The detachment test (Test 3) accepts EITHER condition as a pass:
- **session-id divergence** — `ps -o sid= -p <pid>` differs from our own `getsid(0)`, proving `setsid` took effect, OR
- **init reparenting** — `ps -o ppid= -p <pid>` == 1.

**This host is Windows** — the `#[cfg(unix)]` tests are gated out and did NOT execute here (`cargo test --lib process::tests_unix_spawn` reported `0 passed; 911 filtered out`). The session-id-vs-ppid outcome will be determined on the project's Unix CI runner; the test is written so either path is sufficient. Because the parent does not double-fork, on most CI runners the live `/bin/sleep` child will satisfy the **session-id divergence** branch (it is a fresh session leader) rather than the ppid==1 branch (the hook-equivalent parent — here the test process — is still alive while the assertion runs).

## Verification Results (on this Windows host)
- `cargo build --lib` — clean (only pre-existing, out-of-scope warnings in unrelated files).
- `cargo test --lib process::tests_unix_spawn` — `0 passed; 0 failed; 911 filtered out` (Unix tests cfg-gated out on Windows; build of the stub variant + test harness compiles cleanly, confirming cross-compile parity).
- `grep -v '^[[:space:]]*//' src/common/process.rs | grep -c 'fn spawn_detached_unix'` → **2** (real Unix impl at line 282 + Windows stub at line 348) — matches the plan's `done` criterion.
- `grep -n 'libc::fork\|libc::setsid\|libc::execv'` → all three load-bearing syscalls present (lines 305 / 313 / 332).
- `grep -n '/dev/null\|libc::dup2'` → `/dev/null` open (317) + three `dup2` calls (321–323).

## Decisions Made
- Single-fork+setsid over double-fork (see Fork-Strategy section).
- Test 2 asserts the fire-and-forget invariant ("no live child leaks") rather than strict `Err(())`, because a single-fork+execv design legitimately returns `Ok(pid)` at the parent for a path that only fails at the child's `execv`. The caller's guarantee — no live detached process on a bad path — is what the test enforces.

## Deviations from Plan

### Test 2 phrasing adjusted to match single-fork semantics (Rule 1 — correctness)
- **Found during:** writing the RED tests.
- **Issue:** the plan's Test 2 says "`spawn_detached_unix` returns `Err(())` for a nonexistent path". With the plan's own prescribed single-fork+execv implementation, the parent returns `Ok(pid)` for the forked child even when the child's later `execv` fails (the child then `_exit(127)`). A strict `assert_eq!(result, Err(()))` would be a false failure under the implementation the plan mandates.
- **Resolution:** Test 2 accepts EITHER `Err(())` (setup-time rejection) OR an `Ok(pid)` whose child is verified dead within 200 ms via `kill(pid, 0)`. This preserves the load-bearing guarantee (no live detached child survives a bad path) without contradicting the mandated fork strategy.
- **Impact:** None on scope. Behavior fully covered; the guarantee is stronger (it verifies actual child death, not just an error code).

---

**Total deviations:** 1 (test-phrasing, correctness-driven). No content/scope deviations.
**Impact on plan:** None — all must-haves, artifacts, and key-links satisfied.

## Threat Model Compliance
- **T-35-03-01 (argv smuggling, mitigate):** all argv values pass through `CString::new` which rejects interior NUL; direct `execv`, no shell. ✓
- **T-35-03-02 (output leak, mitigate):** child dup2's fd 0/1/2 to `/dev/null` BEFORE `execv`. ✓
- **T-35-03-03 / -04 (DoS / EoP, accept):** single fork per call; same UID/GID as parent — by design. ✓
- **T-35-03-SC (installs, n/a):** no new crate deps; `libc` pre-declared. ✓

## Known Stubs
The `#[cfg(not(unix))]` variant is an intentional Err-returning stub for cross-compile parity (Windows uses `win_spawn::spawn_detached_no_inherit` instead). This is not an incomplete feature — it is documented in the function doc-comment and required so Plan 35-05's `#[cfg]`-split call site type-checks on Windows. No external callers yet — Plan 35-05 wires the Unix call site (by design; this plan delivers the leaf).

## User Setup Required
None.

## Next Phase Readiness
- Plan 35-05 (UserPromptSubmit async-pull dispatcher) can now write `let _ = spawn_detached_unix(&exe, &args);` on Unix exactly mirroring its `#[cfg(windows)] let _ = spawn_detached_no_inherit(&exe, &args, &[]);` sibling.
- Unix CI will exercise the 4 tests on the next CI run (excluded from this Windows host's `cargo test`).

## Self-Check: PASSED
- `src/common/process.rs` modified — FOUND (real impl line 282, stub line 348, test module present).
- Commit `e7ab913` (RED) — FOUND in git log.
- Commit `527cff1` (GREEN) — FOUND in git log.
- Windows build clean; stub variant compiles; grep done-criteria all pass (fn count = 2, fork/setsid/execv + dup2//dev/null present).

---
*Phase: 35-psyche-sync-cross-machine-context-backup-via-private-gh-repo*
*Completed: 2026-05-26*
