Skip to content

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

Open

Description

@danielweinmann

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}, then SIG_SETMASK back 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:

  1. Thread A installs a handler for SIGTERM: SIG_BLOCK {TERM}. Every thread now blocks TERM.
  2. Thread B starts a process on the fork path and calls blockUserSignals. savedSignals becomes the current mask, which already holds TERM.
  3. Thread A restores its old mask with SIG_SETMASK {}, which unblocks every thread.
  4. Thread B forks. The child restores savedSignals and execs with TERM blocked. The parent calls unblockUserSignals, so SIG_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.hs prints its own blocked and ignored signals by number.
  • Spawner.hs installs 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_sigmask in stg_sig_install and in blockUserSignals / 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 with POSIX_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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions