fix(subprocess): give sanitized builds a wider spawn-retry window

The exponential backoff shipped in v0.10.3 (10/20/40/80/160/320ms, ~0.6s) fixed
the ordinary case. `subprocess_run_spawn_failure` then kept failing on
`test-tsan (macos-14)` — twice on the same SHA, on a PR whose entire diff was a
shell contract plus one line in test.sh, so causation was impossible.

ThreadSanitizer runs several times slower and holds far more process state, so
the pressure window it creates is simply longer than 0.6s. The budget was right
for a normal build and short for a sanitized one.

Raising it for everyone would be the wrong fix: an unsanitized machine that is
genuinely out of capacity should fail fast rather than hang for seconds. So the
extra patience is scoped to the builds that need it — three more doublings,
about 5s, under CBM_SANITIZED_BUILD or __SANITIZE_*__ only. Same shape as the
daemon announce backstop in test_daemon_frontend.c, and for the same reason.

Also fixes a bug in the backoff itself: the shift was capped at a hard-coded 6,
so with a budget of 9 the final three waits would have flattened to 320ms each
instead of continuing to double — the extra attempts would have bought a third
of the intended time. The cap now follows the budget.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-08-13 18:40:46 +02:00
1 parent 7abdd8dc50
commit a92b6be9e3
1 file changed
+26 -1
+26 -1
View File
@@ -885,7 +885,27 @@ static cbm_proc_poll_t cbm_subprocess_poll_win(cbm_subprocess_t *process, cbm_pr
/* 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. */
/* A sanitized build needs a wider window than an ordinary one, and only a
* sanitized one does.
*
* The exponential backoff (10/20/40/80/160/320ms, ~0.6s) fixed the ordinary
* case. `subprocess_run_spawn_failure` then kept failing on `test-tsan` — twice
* on one SHA, on a PR whose diff was a shell contract and a line in test.sh, so
* causation was impossible. ThreadSanitizer runs several times slower and holds
* far more process state, so the pressure window it creates is simply longer
* than 0.6s.
*
* Raising the budget for everyone would be the wrong fix: an unsanitized
* machine that is genuinely out of capacity should still fail fast rather than
* hang for seconds. So the extra patience is scoped to the builds that need it,
* the same way the daemon announce backstop is (test_daemon_frontend.c). Three
* more doublings take the sanitized ceiling to roughly 5s. */
#if defined(CBM_SANITIZED_BUILD) || defined(__SANITIZE_ADDRESS__) || \
defined(__SANITIZE_MEMORY__) || defined(__SANITIZE_THREAD__)
enum { CBM_SPAWN_RETRY = 2, CBM_SPAWN_RETRY_ATTEMPTS = 9 };
#else
enum { CBM_SPAWN_RETRY = 2, CBM_SPAWN_RETRY_ATTEMPTS = 6 };
#endif
/* Exponential backoff: 10, 20, 40, 80, 160, 320ms — ~630ms of total patience.
*
@@ -921,7 +941,12 @@ static bool cbm_spawn_eagain_injected(void) {
#endif
static void cbm_spawn_backoff(int attempt) {
long ms = 10L << (attempt < 6 ? attempt : 6);
/* Cap the shift at the budget, not at a constant: with the sanitized budget
* of 9 a hard-coded 6 would flatten the last three waits to 320ms each
* instead of continuing to double. The budget is the only bound needed —
* callers never exceed it, and a second clamp would be dead code. */
int shift = attempt < CBM_SPAWN_RETRY_ATTEMPTS ? attempt : CBM_SPAWN_RETRY_ATTEMPTS;
long ms = 10L << shift;
struct timespec delay = {ms / 1000L, (ms % 1000L) * 1000L * 1000L};
(void)cbm_nanosleep(&delay, NULL);
}