Skip to content

getProcessExitCode/waitForProcess should throw ECHILD rather than return ExitSuccess for non-child process #359

Open

Description

@alt-romes

https://www.man7.org/linux/man-pages/man2/wait.2.html

       ECHILD (for waitpid() or waitid()) The process specified by pid
              (waitpid()) or idtype and id (waitid()) does not exist or
              is not a child of the calling process.  (This can happen
              for one's own child if the action for SIGCHLD is set to
              SIG_IGN.  See also the Linux Notes section about threads.)

In https://github.com/well-typed/haskell-debugger, it took me a long while to uncover a bug that turned out to be caused by getProcessExitCode incorrectly returning Just ExitSuccess for a handle of a non-child process.

I'm "unsafely" (i.e with .Internals) constructing the handle from the PID of a process that was not spawned/is not a child of the process calling getProcessExitCode. It should throw something like ECHILD, following the manpage for wait.

I suspect waitForProcess should also fail with ECHILD here.

I've attached the AI generated counter example program I was using to observe the incorrect behavior outside of the much more complicated haskell-debugger

NonChildProcessHandleDemo.txt

Activity

  1. tomjaguarpaw commented on Apr 17, 2026

    @tomjaguarpaw
    Member

    Hi @alt-romes, what do you mean it should "throw" ECHILD? Do you mean it should throw some sort of exception with the same meaning as the ECHILD error code? Do you have candidate for the type of this exception in mind, or would it have to be a new one?

    NonChildProcessHandleDemo.txt

    Would it be possible to embed that code in a comment here (perhaps using the <details> and <summary> tags to collapse it)? It currently requires a download to view, which is inconvenient.

  2. MangoIV commented on Apr 22, 2026

    @MangoIV
    Contributor
    The Code
    module Main where
    
    import Control.Concurrent (threadDelay)
    import Control.Exception (IOException, try)
    import System.IO (BufferMode(LineBuffering), hGetLine, hSetBuffering)
    import System.Posix.Signals (nullSignal, signalProcess)
    import System.Posix.Types (ProcessID)
    import System.Process
    import System.Process.Internals (mkProcessHandle)
    import Text.Read (readMaybe)
    
    main :: IO ()
    main = do
      putStrLn "== Real child created by this process =="
      (_, _, _, childPh) <- createProcess (proc "sleep" ["10"])
      childPid <- getPid childPh >>= maybe (fail "sleep child has no PID") pure
      checkHandle "real child" childPid childPh 3
    
      putStrLn ""
      putStrLn "== Live PID reported by another process =="
      orphanPid <- launchOrphanSleep
      putStrLn $ "launcher reported orphan PID " ++ show orphanPid
      orphanPh <- mkProcessHandle orphanPid False
      checkHandle "forged handle" orphanPid orphanPh 3
    
    launchOrphanSleep :: IO ProcessID
    launchOrphanSleep = do
      (_, Just hout, _, launcherPh) <-
        createProcess
          (proc "sh" ["-c", "sleep 10 >/dev/null 2>&1 & echo $!"])
            { std_out = CreatePipe
            }
      hSetBuffering hout LineBuffering
      pidLine <- hGetLine hout
      _ <- waitForProcess launcherPh
      case readMaybe pidLine of
        Just pid -> pure pid
        Nothing -> fail ("could not parse orphan PID: " ++ show pidLine)
    
    checkHandle :: String -> ProcessID -> ProcessHandle -> Int -> IO ()
    checkHandle label pid ph polls = loop 1
      where
        loop tick
          | tick > polls = pure ()
          | otherwise = do
              exitCode <- getProcessExitCode ph
              alive <- isProcessAlive pid
              putStrLn $
                label
                  ++ " poll "
                  ++ show tick
                  ++ ": getProcessExitCode = "
                  ++ show exitCode
                  ++ ", processAlive = "
                  ++ show alive
              threadDelay 1_000_000
              loop (tick + 1)
    
    isProcessAlive :: ProcessID -> IO Bool
    isProcessAlive pid = do
      result <- try (signalProcess nullSignal pid) :: IO (Either IOException ())
      pure $ case result of
        Right () -> True
        Left _ -> False
  3. alt-romes commented on Apr 22, 2026

    @alt-romes
    Author

    @tomjaguarpaw yes, some Haskell exception to mean the underling ECHILD error code. I don't know whether one already exists to represent it.

  4. tomjaguarpaw commented on May 9, 2026

    @tomjaguarpaw
    Member

    Digging into this more, it seems that the issue is that you created a ProcessHandle using internals in a way that violates the assumed invariant of that type (that it always refers to a child process), and you are saying that violation should be detected by getProcessExitCode (and other functions that take ProcessHandle as an argument) and it should throw an exception instead of returning the wrong result. Is that a correct interpretation?

    Alternatively, does it make sense to say it is not an invariant of ProcessHandle that it refers to a child process, and nonetheless this condition should be detected by the various functions in the API here?


    Regarding implementation, ECHILD is detected and we return 1 from the C getProcessExitCode:

    if (errno == ECHILD)
    {
    *pExitCode = 0;
    return 1;
    }

    1 is treated as "it has exited":

    process/System/Process.hs

    Lines 857 to 866 in e27e2e2

    res <- throwErrnoIfMinus1Retry "getProcessExitCode" $
    c_getProcessExitCode h pExitCode
    code <- peek pExitCode
    if res == 0
    then return (p_, (Nothing, False))
    else do
    closePHANDLE h
    let e | code == 0 = ExitSuccess
    | otherwise = ExitFailure (fromIntegral code)
    return (ClosedHandle e, (Just e, True))

    That certainly seems odd to me.

    Related:

    • Reversion of similar commit: 4575acb
    • Original implementation: 32d696a

    I personally can' t see anything wrong with removing that special case for ECHILD. I believe this would give you the behaviour you want, @alt-romes. Does anyone else have any clearer idea (especially someone with more posix experience)?

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