Repository navigation
On macOS, installing a signal handler while another capability starts a process can leave signals blocked for good, in the parent and in every later child #384
Description
Environment: GHC 9.10.3, process 1.6.26.1, unix 2.8.7.0, macOS 26.3.1 (arm64), -threaded +RTS -N4.
Related: GHC #10686 (SIGINT stops working after createProcess; one saved-signals slot shared by the RTS and process) looks like the same root cause, reported on GHC 7.10.1. This report adds a reproduction and the macOS mechanism. GHC #2870 is about the same fork window.
What happens. On Darwin, sigprocmask(2) sets the mask of every thread in the process. XNU's bsd/kern/kern_sig.c implements it with block_procsigmask / set_procsigmask, which walk the process's thread list. Only pthread_sigmask is per-thread. The RTS calls sigprocmask in two places:
stg_sig_install:SIG_BLOCK {sig}, thenSIG_SETMASKback to the old mask;blockUserSignals/unblockUserSignals, which save the mask in one global,savedSignals.
process's fork path (close_fds = True) calls the second pair around fork. process's lock serializes spawns against each other, but not against handler installs. With two capabilities, this can happen:
- Thread A installs a handler for SIGTERM:
SIG_BLOCK {TERM}. Every thread now blocks TERM. - Thread B starts a process on the fork path and calls
blockUserSignals.savedSignalsbecomes the current mask, which already holds TERM. - Thread A restores its old mask with
SIG_SETMASK {}, which unblocks every thread. - Thread B forks. The child restores
savedSignalsand execs with TERM blocked. The parent callsunblockUserSignals, soSIG_SETMASK {TERM}lands on every thread, for good. Every later child inherits it.
The posix_spawn path is affected too: a child started between steps 1 and 3 inherits the signal that is blocked for that moment.
A blocked mask survives exec. So a child started this way never sees SIGTERM or SIGINT. A supervisor that passes a stop on to it waits for ever.
Reproduction. Two standalone programs, included at the end of this report:
Probe.hsprints its own blocked and ignored signals by number.Spawner.hsinstalls Haskell handlers for SIGTERM, SIGINT and SIGHUP. It runs THREADS × EACH spawns of the probe, while one more thread keeps reinstalling handlers for SIGTERM, SIGINT, SIGHUP and SIGUSR1 (churn). It then prints the parent's mask, and a tally of what the children printed.
ghc -O -threaded -o probe Probe.hs
ghc -O -threaded -rtsopts -o spawner Spawner.hs
./spawner ./probe 16 40 churn fork +RTS -N4 -RTS # PATH: fork | spawn | mixed; churn | none
Results on the environment above:
| Run | Parent's mask at the end | Children (out of 640) |
|---|---|---|
fork path, -N4, churn |
1, 2, 15, 30 blocked | 603 with 1, 2, 15 and 30 blocked; the other 37 with some of them blocked |
posix_spawn path, -N4, churn |
clean | 288 inherit one blocked signal; 352 clean |
fork path, -N4, no churn |
clean | all clean |
either path, -N1, churn |
clean | all clean |
Expected: the parent's mask is unchanged after a spawn or a handler install. Every child starts with the mask the parent had before either call.
Suggested fix:
- RTS: use
pthread_sigmaskinstg_sig_installand inblockUserSignals/unblockUserSignals. Keep the saved mask per call, not in a global. - process: keep the saved mask on the C stack in the fork path. Give the child an explicit empty mask (or the caller's own), rather than
savedSignals. On the posix_spawn path, set it withPOSIX_SPAWN_SETSIGMASK.
Workaround: run on one capability, or reset the mask and dispositions at the start of every child you control.
Probe.hs
{-# LANGUAGE CApiFFI #-}
-- Prints this process's blocked signals and ignored dispositions, by number.
module Main (main) where
import Foreign
import Foreign.C.Types
import System.IO
import System.Posix.Signals (getSignalMask, inSignalSet)
foreign import capi unsafe "signal.h sigaction" c_sigaction :: CInt -> Ptr () -> Ptr () -> IO CInt
main :: IO ()
main = do
mask <- getSignalMask
let blocked = [s | s <- [1 .. 31], s `inSignalSet` mask]
ignored <- filterM' (\s -> allocaBytes 256 $ \a -> fillBytes a 0 256 >> c_sigaction s nullPtr a >> (== intPtrToPtr 1) <$> peek (castPtr a :: Ptr (Ptr ()))) [1..31]
hPutStrLn stdout ("blocked " <> show blocked <> " ignored " <> show ignored)
filterM' :: (a -> IO Bool) -> [a] -> IO [a]
filterM' p = fmap concat . mapM (\x -> (\b -> [x | b]) <$> p x)Spawner.hs
-- Many concurrent spawns from a process with Haskell handlers for TERM, INT
-- and HUP, through process's fork path (close_fds) and its posix_spawn path,
-- while other threads install handlers again. Each child reports its own
-- blocked signals and ignored dispositions; this tallies them.
module Main (main) where
import Control.Concurrent
import Control.Monad
import Data.IORef
import qualified Data.Map.Strict as M
import System.Environment
import System.Posix.Signals
import System.Process
import System.IO
main :: IO ()
main = do
[probe, threadsS, eachS, churnS, pathS] <- getArgs
let threads = read threadsS :: Int; each = read eachS :: Int; churn = churnS == "churn"
forM_ [sigTERM, sigINT, sigHUP] $ \s -> installHandler s (Catch (pure ())) Nothing
tally <- newIORef M.empty
when churn . void . forkIO . forever $ do
forM_ [sigTERM, sigINT, sigHUP, sigUSR1] $ \s -> installHandler s (Catch (pure ())) Nothing
yield
dones <- forM [1 .. threads] $ \t -> do
done <- newEmptyMVar
_ <- forkIO $ do
forM_ [1 .. each] $ \i -> do
let fork = case pathS of { "fork" -> True; "spawn" -> False; _ -> even (t + i) }
(_, Just out, _, h) <- createProcess (proc probe []) {std_out = CreatePipe, close_fds = fork}
line <- hGetLine out
hClose out
_ <- waitForProcess h
atomicModifyIORef' tally (\m -> (M.insertWith (+) ((if fork then "fork " else "spawn ") <> line) (1 :: Int) m, ()))
putMVar done ()
pure done
mapM_ takeMVar dones
mask <- getSignalMask
putStrLn ("parent main thread blocked " <> show [s | s <- [1 .. 31], s `inSignalSet` mask])
readIORef tally >>= mapM_ (\(k, v) -> putStrLn (show v <> " " <> k)) . M.toList
Activity