fix(subprocess): retry a spawn the kernel refused for load, not for cause

`posix_spawnp` and `fork` both fail with EAGAIN when the process table or a
per-user limit is momentarily full. Both paths treated that as a hard failure,
so a burst of process creation turned into CBM_PROC_SPAWN_FAILED — a git probe,
an LSP server, or an index worker refusing to run for a reason the user can
neither see nor act on, on a machine that is merely busy.

This is the production half of a flake we have been re-running for two days:
`subprocess_run_clean` failed spawning `/bin/sh -c "exit 0"` on macOS runners in
three separate CI runs, always under a loaded release matrix, always green on a
quiet rerun. Chasing it as a test race would have been chasing the symptom — the
test is right, and a user on a loaded laptop hits the same edge.

EAGAIN/ENOMEM now retry three times over ~30ms; everything else keeps failing
immediately, and an exhausted system still fails after the backoff rather than
spinning. exec-class errors (ENOENT/EACCES/ENOEXEC/...) are untouched, so the
documented "missing binary exits 127, spawn_failed means the mechanism failed"
contract still holds. The fork fallback gets the same treatment: it fails for
the same reason under the same pressure, and the fallback must not be less
robust than the primary path.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-08-12 11:52:38 +02:00
1 parent 4b886904d7
commit 98aff5b7dc
1 file changed
+38 -2
+38 -2
View File
@@ -882,6 +882,26 @@ static cbm_proc_poll_t cbm_subprocess_poll_win(cbm_subprocess_t *process, cbm_pr
#else /* POSIX */
/* Transient spawn-failure retry (see the EAGAIN note in cbm_posix_spawn_apple).
* Three attempts over ~30ms total: long enough to ride out a burst of process
* creation, short enough that a genuinely exhausted system still fails fast. */
enum { CBM_SPAWN_RETRY = 2, CBM_SPAWN_RETRY_ATTEMPTS = 3 };
static const struct timespec CBM_SPAWN_RETRY_DELAY = {0, 10L * 1000L * 1000L};
#define CBM_SPAWN_RETRY_DELAY_NS (&CBM_SPAWN_RETRY_DELAY)
/* fork() fails with EAGAIN under the same pressure posix_spawn does, and the
* fallback path must not be less robust than the primary one. */
static pid_t cbm_fork_with_retry(void) {
for (int attempt = 0; attempt < CBM_SPAWN_RETRY_ATTEMPTS; attempt++) {
pid_t pid = fork();
if (pid >= 0 || (errno != EAGAIN && errno != ENOMEM)) {
return pid;
}
(void)cbm_nanosleep(CBM_SPAWN_RETRY_DELAY_NS, NULL);
}
return fork();
}
/* Used by the fork+exec child. posix_spawn performs the same reset
* declaratively via SETSIGDEF + SETSIGMASK, but Apple still forks for the
* exec-failure fallback below, so this stays compiled everywhere. */
@@ -1010,6 +1030,14 @@ static int cbm_posix_spawn_apple(cbm_subprocess_t *process, int input, int outpu
rc == ELOOP || rc == ENAMETOOLONG || rc == ENOTDIR)) {
return 1;
}
/* EAGAIN/ENOMEM are the kernel saying "not right now", not "never": the
* process table or a per-user limit is momentarily full. Reporting
* spawn_failed for that turns transient load into a user-visible error —
* a git or LSP probe failing on a busy laptop for no reason the user can
* see or act on. Retry briefly. Everything else stays a hard failure. */
if (configured && (rc == EAGAIN || rc == ENOMEM)) {
return CBM_SPAWN_RETRY;
}
return -1;
}
#endif
@@ -1059,13 +1087,21 @@ static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) {
pid_t pid = -1;
#ifdef __APPLE__
int spawn_rc = cbm_posix_spawn_apple(process, input, output, &pid);
for (int attempt = 0; spawn_rc == CBM_SPAWN_RETRY && attempt < CBM_SPAWN_RETRY_ATTEMPTS;
attempt++) {
(void)cbm_nanosleep(CBM_SPAWN_RETRY_DELAY_NS, NULL);
spawn_rc = cbm_posix_spawn_apple(process, input, output, &pid);
}
if (spawn_rc == CBM_SPAWN_RETRY) {
spawn_rc = -1; /* still exhausted after backoff: a real failure */
}
if (spawn_rc < 0) {
(void)close(input);
(void)close(output);
return -1;
}
if (spawn_rc > 0) { /* exec-class failure: reproduce the fork+exec 127 */
pid = fork();
pid = cbm_fork_with_retry();
if (pid < 0) {
(void)close(input);
(void)close(output);
@@ -1076,7 +1112,7 @@ static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) {
}
}
#else
pid = fork();
pid = cbm_fork_with_retry();
if (pid < 0) {
(void)close(input);
(void)close(output);