Wrap process control: fork, exec*, waitpid, posix_spawn, the environment #11

Open
opened 2026-08-02 18:53:24 -04:00 by tachikoma · 2 comments
Collaborator

Source: TODO.md §4.1 (at 669b2b3)

  • fork, the exec* family, waitpid, wait
  • posix_spawn
  • system, popen, pclose
  • getpid, getppid, getuid, geteuid, setuid, setgid
  • atexit, exit, _exit, abort -- mostly to give akerror a shutdown hook
  • getenv, setenv, unsetenv, putenv, clearenv

fork needs thinking about before it is wrapped: the error pool is inherited
by the child, and any context live at the moment of the fork exists twice
afterwards.
That is a decision to make deliberately, not something to discover
from a double release.

Files: src/, include/akstdlib.h


Filed by Tachikoma (Claude Code, Opus 5, 1M context)

**Source:** TODO.md §4.1 (at 669b2b3) - [ ] `fork`, the `exec*` family, `waitpid`, `wait` - [ ] `posix_spawn` - [ ] `system`, `popen`, `pclose` - [ ] `getpid`, `getppid`, `getuid`, `geteuid`, `setuid`, `setgid` - [ ] `atexit`, `exit`, `_exit`, `abort` -- mostly to give akerror a shutdown hook - [ ] `getenv`, `setenv`, `unsetenv`, `putenv`, `clearenv` **`fork` needs thinking about before it is wrapped:** the error pool is inherited by the child, and **any context live at the moment of the fork exists twice afterwards.** That is a decision to make deliberately, not something to discover from a double release. **Files:** `src/`, `include/akstdlib.h` --- Filed by Tachikoma (Claude Code, Opus 5, 1M context)
tachikoma added this to the 0.3.0 milestone 2026-08-02 18:53:24 -04:00
tachikoma added the api-gapdesign-decisionblast-radius:mediumstatus::grooming labels 2026-08-02 18:53:24 -04:00
Owner

@tachikoma let's get a better plan on this

@tachikoma let's get a better plan on this
Author
Collaborator

Plan

This ticket is six checkboxes covering roughly twenty calls, and they do not share
a decision. One of them is blocked on libakerror and the rest are not, so as one
issue it cannot leave status::grooming until the hardest item is settled — which
holds up the easiest. Steps:

  • Split it by decision boundary
  • Settle the fork question, and file what it needs upstream
  • Settle the "cannot fail" question
  • Say what system and popen are for
  • Sequence the pieces

Split it

Piece Decision it carries Blocked?
Environment: getenv, setenv, unsetenv, putenv, clearenv Whether getenv returning NULL is success No
Identity: getpid, getppid, getuid, geteuid, setuid, setgid The "cannot fail" question No
Termination: atexit, exit, _exit, abort Whether akerror gets a shutdown hook No
Creation: fork, exec*, posix_spawn, wait, waitpid What the child's error pool contains Yes — on libakerror
Shell-out: system, popen, pclose Whether they belong in this library at all No

Recommendation: five issues. The environment piece is a clean afternoon and
has no open question; it should not wait on fork.

The fork question

The ticket says a context live at the fork exists twice afterwards. Worth being
precise about what that does and does not cost, because the phrase "double
release" suggests a sharper failure than the one that is actually there.

AKERR_ARRAY_ERROR is a fixed static array (akerror.tmpl.h:103). After fork
the child gets its own copy-on-write duplicate, not shared memory. So parent and
child each release their own copy and there is no cross-process double free.

The real cost is narrower and quieter: the child's pool comes up holding slots
that describe the parent's in-flight work. Nothing in the child will ever
release them, because the code that would have is in the other process. If the
child execs or _exits immediately, this costs nothing. If the child keeps
running — which is the whole point of fork without exec — it has permanently
lost those slots, and the pool is bounded.

That makes the fix a pool reset in the child between fork returning 0 and the
wrapper returning. libakerror exposes no such call. akerr_init() is the only
thing close and it reserves status ranges as a side effect, so calling it twice is
not obviously safe.

Recommendation: file an issue against libakerror for a post-fork pool reset —
either an explicit akerr_reset_pool() or a pthread_atfork child handler
installed by akerr_init(). AGENTS.md is explicit that a defect in a dependency
is filed against that dependency, and libakerror already carries #14 (IGNORE()
leaks a pool slot), which is the same resource under a different failure. Until
that lands, fork stays unwrapped and TODO.md's "Not wrapped" table says why.

An atfork handler is the better of the two: it is correct for a caller who calls
fork(2) directly, and this library cannot make that caller go away.

posix_spawn is worth calling out as the unblocked half of this piece. It never
returns twice, so the pool question does not arise, and it covers the
fork-then-exec case that is most of the real uses. It could ship while fork
waits.

The "cannot fail" question

getpid, getppid, getuid and geteuid are defined never to fail. Their
wrappers would take no pointer that can be NULL beyond the out-param, and raise
nothing but AKERR_NULLPOINTER.

This is the same question as rewinddir in #10 and umask in #9, and it
should get one answer for all three. Two defensible ones:

  • Wrap them. A gap in a family is worse than a thin function, and a caller
    writing aksl_ everywhere should not have to remember which four calls are
    spelled differently.
  • Do not wrap them. They are already safe. A wrapper that cannot fail is
    ceremony, and TODO.md's "Not wrapped" table exists precisely to record this.

I lean toward wrapping, on the consistency argument — the cost is a few lines
each and the alternative is a caller checking the header every time. But it is a
taste call, not a correctness one, and it should be made once and written down
rather than settled per-function.

setuid and setgid genuinely fail (EPERM) and are not part of this question.

system and popen

These take a shell command string, so every caller is one interpolation away from
a command injection, and system additionally blocks SIGINT/SIGQUIT in ways
that surprise people.

popen also returns a FILE * that must be closed with pclose rather than
fclose — and aksl_fclose exists and will happily accept it. That is a real
trap this library would be introducing: a wrapper family where two members
return interchangeable-looking handles that need different close calls.

Recommendation: wrap popen/pclose, and do not wrap system. popen has
uses that nothing else covers; system is posix_spawn plus a shell, and the
shell is the part that hurts. If popen lands, aksl_pclose needs a loud
@warning on both it and aksl_fclose.

Sequencing

  1. Environment — no decisions, unblocks callers today
  2. Termination hooks — atexit is what would give akerror a shutdown hook, so it
    is worth doing before the pool work rather than after
  3. Identity — after the "cannot fail" answer
  4. popen/pclose — independent
  5. posix_spawn, wait, waitpid — independent of the pool question
  6. fork and exec* — after the libakerror issue lands

What I would change on the ticket

Close this one as split, or keep it as the tracking issue and open the five
underneath it. Either way the fork piece should carry status::blocked with a
link to the libakerror issue, so it is visible that it is waiting on something
rather than unloved.

I can file the libakerror issue and the split issues on request — say the word and
I will, rather than doing it unasked.


— Tachikoma (Claude Code, Opus 5, 1M context)

## Plan This ticket is six checkboxes covering roughly twenty calls, and they do not share a decision. One of them is blocked on `libakerror` and the rest are not, so as one issue it cannot leave `status::grooming` until the hardest item is settled — which holds up the easiest. Steps: - Split it by decision boundary - Settle the `fork` question, and file what it needs upstream - Settle the "cannot fail" question - Say what `system` and `popen` are for - Sequence the pieces ### Split it | Piece | Decision it carries | Blocked? | |---|---|---| | Environment: `getenv`, `setenv`, `unsetenv`, `putenv`, `clearenv` | Whether `getenv` returning NULL is success | No | | Identity: `getpid`, `getppid`, `getuid`, `geteuid`, `setuid`, `setgid` | The "cannot fail" question | No | | Termination: `atexit`, `exit`, `_exit`, `abort` | Whether akerror gets a shutdown hook | No | | Creation: `fork`, `exec*`, `posix_spawn`, `wait`, `waitpid` | What the child's error pool contains | **Yes — on libakerror** | | Shell-out: `system`, `popen`, `pclose` | Whether they belong in this library at all | No | **Recommendation: five issues.** The environment piece is a clean afternoon and has no open question; it should not wait on `fork`. ### The `fork` question The ticket says a context live at the fork exists twice afterwards. Worth being precise about what that does and does not cost, because the phrase "double release" suggests a sharper failure than the one that is actually there. `AKERR_ARRAY_ERROR` is a fixed static array (`akerror.tmpl.h:103`). After `fork` the child gets its own copy-on-write duplicate, not shared memory. So parent and child each release their own copy and **there is no cross-process double free**. The real cost is narrower and quieter: the child's pool comes up holding slots that describe *the parent's* in-flight work. Nothing in the child will ever release them, because the code that would have is in the other process. If the child `exec`s or `_exit`s immediately, this costs nothing. If the child keeps running — which is the whole point of `fork` without `exec` — it has permanently lost those slots, and the pool is bounded. That makes the fix a pool reset in the child between `fork` returning 0 and the wrapper returning. **libakerror exposes no such call.** `akerr_init()` is the only thing close and it reserves status ranges as a side effect, so calling it twice is not obviously safe. **Recommendation: file an issue against `libakerror`** for a post-fork pool reset — either an explicit `akerr_reset_pool()` or a `pthread_atfork` child handler installed by `akerr_init()`. AGENTS.md is explicit that a defect in a dependency is filed against that dependency, and libakerror already carries **#14** (`IGNORE()` leaks a pool slot), which is the same resource under a different failure. Until that lands, `fork` stays unwrapped and `TODO.md`'s "Not wrapped" table says why. An `atfork` handler is the better of the two: it is correct for a caller who calls `fork(2)` directly, and this library cannot make that caller go away. `posix_spawn` is worth calling out as the *unblocked* half of this piece. It never returns twice, so the pool question does not arise, and it covers the fork-then-exec case that is most of the real uses. It could ship while `fork` waits. ### The "cannot fail" question `getpid`, `getppid`, `getuid` and `geteuid` are defined never to fail. Their wrappers would take no pointer that can be NULL beyond the out-param, and raise nothing but `AKERR_NULLPOINTER`. This is the same question as `rewinddir` in **#10** and `umask` in **#9**, and it should get one answer for all three. Two defensible ones: - **Wrap them.** A gap in a family is worse than a thin function, and a caller writing `aksl_` everywhere should not have to remember which four calls are spelled differently. - **Do not wrap them.** They are already safe. A wrapper that cannot fail is ceremony, and `TODO.md`'s "Not wrapped" table exists precisely to record this. **I lean toward wrapping**, on the consistency argument — the cost is a few lines each and the alternative is a caller checking the header every time. But it is a taste call, not a correctness one, and it should be made once and written down rather than settled per-function. `setuid` and `setgid` genuinely fail (`EPERM`) and are not part of this question. ### `system` and `popen` These take a shell command string, so every caller is one interpolation away from a command injection, and `system` additionally blocks `SIGINT`/`SIGQUIT` in ways that surprise people. `popen` also returns a `FILE *` that must be closed with `pclose` rather than `fclose` — and `aksl_fclose` exists and will happily accept it. That is a real trap this library would be *introducing*: a wrapper family where two members return interchangeable-looking handles that need different close calls. **Recommendation: wrap `popen`/`pclose`, and do not wrap `system`.** `popen` has uses that nothing else covers; `system` is `posix_spawn` plus a shell, and the shell is the part that hurts. If `popen` lands, `aksl_pclose` needs a loud `@warning` on both it and `aksl_fclose`. ### Sequencing 1. Environment — no decisions, unblocks callers today 2. Termination hooks — `atexit` is what would give akerror a shutdown hook, so it is worth doing before the pool work rather than after 3. Identity — after the "cannot fail" answer 4. `popen`/`pclose` — independent 5. `posix_spawn`, `wait`, `waitpid` — independent of the pool question 6. `fork` and `exec*` — after the libakerror issue lands ### What I would change on the ticket Close this one as split, or keep it as the tracking issue and open the five underneath it. Either way the `fork` piece should carry `status::blocked` with a link to the libakerror issue, so it is visible that it is waiting on something rather than unloved. I can file the libakerror issue and the split issues on request — say the word and I will, rather than doing it unasked. --- *— Tachikoma (Claude Code, Opus 5, 1M context)*
Sign in to join this conversation.