From b4c981c6138ecb0704ec83fc54a61911ba059a38 Mon Sep 17 00:00:00 2001 From: ian4hu Date: Fri, 16 Oct 2026 20:24:37 +0800 Subject: [PATCH] sigchain: recover from concurrent double-pops of the same signal sigchain_push()/sigchain_pop() update signals[sig].n and the handler stack non-atomically. When two copies of the same signal are delivered in quick succession to different threads, both instances of the handler run the usual "cleanup; sigchain_pop(sig); raise(sig)" pattern concurrently, and both read the same s->n before either one decrements it. Each then installs s->old[s->n - 1], so a two-entry chain unwinds both entries while the dispatcher keeps pointing at a handler that was already popped: n ends at 0 while the effective disposition is still old[1]. The surviving dispatcher then runs that popped handler, whose own sigchain_pop() hits the "n < 1" guard and does nothing, and the raise() at the end redelivers to the same stuck dispatcher forever. The process neither dies nor continues; every signal-handling thread spins at 100% CPU and only SIGKILL recovers it. This is reachable in practice: "git clone --depth 1" over ssh holds two SIGTERM entries in the chain at the index-pack parking point (clone's remove_junk_on_signal, then tempfile's remove_tempfiles_on_signal from the first lockfile), and two SIGTERMs a fraction of a millisecond apart land on two threads and corrupt the chain. Rather than serializing the chain mutation, teach the "n < 1" guard in sigchain_pop() to repair the damage. Track how many pushes were ever performed for the signal; when a pop finds the chain empty but the chain bottom is known, reinstall s->old[0], the disposition that was in place before the chain was built. The stuck handler's raise() then lands on that disposition -- typically SIG_DFL -- and the process dies by it, exactly as a single delivery would have. The repair is inert on every legitimate path to the guard: if the signal was never pushed, nothing is installed; if the chain was fully unwound by regular pops, s->old[0] is already installed and reinstalling it changes nothing. The push/pop fast path is untouched. A fault-injected A/B run of the minimal reproducer, compiled from identical sources with and without this change (the gap between the "read n" and "decrement n" steps of pop widened so both racing pops reliably read the same value; 300 interleaved pairs per arm) froze the unpatched arm 121 times out of 300. The patched arm never froze: all 300 runs reinstated the pre-chain disposition and died by it, and 20/20 single-delivery control runs never reached the repair code. Signed-off-by: ian4hu --- sigchain.c | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/sigchain.c b/sigchain.c index 66123bdbabb041..f6da68ce4d45a0 100644 --- a/sigchain.c +++ b/sigchain.c @@ -7,6 +7,7 @@ struct sigchain_signal { sigchain_fun *old; int n; int alloc; + int pushed; }; static struct sigchain_signal signals[SIGCHAIN_MAX_SIGNALS]; @@ -26,6 +27,7 @@ int sigchain_push(int sig, sigchain_fun f) if (s->old[s->n] == SIG_ERR) return -1; s->n++; + s->pushed++; return 0; } @@ -33,8 +35,21 @@ int sigchain_pop(int sig) { struct sigchain_signal *s = signals + sig; check_signum(sig); - if (s->n < 1) + if (s->n < 1) { + /* + * We are unwinding a chain that is already empty. If we + * got here from a signal handler, two concurrent pops of + * the same signal both decremented "n", leaving the + * dispatcher stuck on a handler that no longer has a + * chain entry: its raise() would re-enter it forever. + * Reinstall the disposition that was in place before the + * chain was built so the raise() that follows lands on + * that instead. + */ + if (s->pushed) + signal(sig, s->old[0]); return 0; + } if (signal(sig, s->old[s->n - 1]) == SIG_ERR) return -1;