Skip to content

runPy, callback and async exception. #48

Description

@guibou

It seems that a piece of code using runPy do not react to async exception: during the FFI call to python, haskell main thread is blocked and cannot propagate the exception to python.

The solutions I am aware are:

For the cooperative version, the idea is to run the runPy in one haskell thread and concurrently another haskell thread which upon interruption will write on the shared memory. Something like:

alloca $ \ptr -> do
   poke ptr 0
   race (the_run_py_thread ptr) $ do
      some_action_to_wait `finally` poke ptr 1

Where the_run_py_thread does the runPy and pass the ptr to the python process which is then responsible to read it regularly and stop its computation if the value change to 1.

Note that "callback" to haskell cannot receive the async exception either, so one solution is to run the callback computation in the main thread, something like:

{-# LANGUAGE QuasiQuotes #-}

import Control.Concurrent
import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.DeepSeq (NFData, force)
import Control.Exception
import Control.Monad
import Control.Monad.IO.Class (MonadIO (..))
import Data.IORef
import Data.Primitive (Ptr)
import Python.Inline
import Python.Inline.QQ (pye, pymain)

main = do
  initializePython
  runPy
    [pymain|
    import math
    def foo(cb):
      i = 0
      while True:
        (shouldStop, res)  = cb(i)
        if shouldStop:
           raise "End"
        i += 1
  |]

  let f :: Int -> IO ()
      f x = do
        if x `mod` 100000 == 0
          then print (x :: Int)
          else pure ()

  withHaskellCallback f $ \cont -> do
    runPy [pye|foo(cont_hs)|]

-- | transform an haskell function into a callback for 'runPy' with an
-- additionnal property that an async exception ends the callback immediatly
-- and it returns (True, None) instead of (False, result)
withHaskellCallback :: (NFData res, ToPy res) => (a -> IO res) -> ((a -> Py PyObject) -> IO x) -> IO x
withHaskellCallback initial_function cont = do
  input <- newEmptyTMVarIO
  output <- newEmptyTMVarIO
  stopVar <- newTVarIO False

  let action_in_ffi = cont $ \x -> do
        stop <- liftIO $ atomically $ do
          stop <- readTVar stopVar
          if stop
            then pure True
            else do
              putTMVar input x
              pure False

        if stop
          then
            toPy (True, ())
          else do
            resM <- liftIO $ atomically $ do
              stop <- readTVar stopVar
              if stop
                then pure Nothing
                else do
                  res <- takeTMVar output
                  pure (Just res)

            case resM of
              Nothing -> toPy (True, ())
              Just res -> toPy (False, res)

      worker_in_main = do
        ( forever $ do
            x <- atomically $ takeTMVar input
            res <- initial_function x
            -- \| Force evaluation to NF
            res_evaluated <- evaluate (force res)
            atomically $ putTMVar output res_evaluated
          )
          `finally` do
            atomically $ do
              writeTVar stopVar True

  result <- race action_in_ffi worker_in_main
  case result of
    Right (e :: ExceptionWithContext SomeException) -> throwIO e
    Left result -> pure result

I'm documenting that workaround because I'm facing this issue at work currently. Maybe we can discuss how a robust solution could be introduced in inline-python.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions