keel-abi-0.1.0.0: test/Managed.hs
-- | Unit tests for the managed layer ("Keel.Abi.Arrow") that need no
-- foreign producer: Haskell plays both exporter and consumer.
module Main (main) where
import Control.Monad (forM_, unless)
import Data.IORef (modifyIORef', newIORef, readIORef)
import Foreign.ForeignPtr (finalizeForeignPtr, withForeignPtr)
import GHC.Stats (GCDetails (..), RTSStats (..), getRTSStats, getRTSStatsEnabled)
import System.Mem (performMajorGC)
import Foreign.Marshal.Alloc (callocBytes, free, mallocBytes)
import Foreign.Ptr (Ptr, castPtr, nullFunPtr, nullPtr)
import Foreign.Storable (peek, peekElemOff, poke, pokeElemOff, sizeOf)
import Keel.Abi.Arrow
import Keel.Abi.Arrow.Raw
import Keel.Abi.DLPack
import Keel.Abi.DLPack.Raw
expect :: Bool -> String -> IO ()
expect ok msg = unless ok (fail msg)
main :: IO ()
main = do
-- 1. releasing a never-filled import target is a no-op (with* variant)
r <- withArrowArrayImport $ \p -> do
a <- peek p
expect (arrayRelease a == nullFunPtr) "import target not zeroed"
pure (42 :: Int)
expect (r == 42) "with-import did not return the action result"
-- 2. export -> consumer-release lifecycle, array side
cnt <- newIORef (0 :: Int)
p <- callocBytes (sizeOf (undefined :: ArrowArray))
exportArrowArray p emptyArrowArray { arrayLength = 3 } (modifyIORef' cnt (+ 1))
a0 <- peek p
expect (arrayLength a0 == 3) "template field lost on export"
expect (arrayRelease a0 /= nullFunPtr) "export installed no release"
releaseArrowArray p
releaseArrowArray p -- double release must be a no-op
n <- readIORef cnt
expect (n == 1) ("cleanup ran " <> show n <> " times, want 1")
a1 <- peek p
expect (arrayRelease a1 == nullFunPtr) "release not nulled"
expect (arrayPrivateData a1 == nullPtr) "private_data not nulled"
free p
-- 3. same lifecycle, schema side
scnt <- newIORef (0 :: Int)
sp <- callocBytes (sizeOf (undefined :: ArrowSchema))
exportArrowSchema sp emptyArrowSchema (modifyIORef' scnt (+ 1))
releaseArrowSchema sp
releaseArrowSchema sp
sn <- readIORef scnt
expect (sn == 1) ("schema cleanup ran " <> show sn <> " times, want 1")
free sp
-- 4. with-import releases an exported struct on scope exit
wcnt <- newIORef (0 :: Int)
withArrowArrayImport $ \wp ->
exportArrowArray wp emptyArrowArray (modifyIORef' wcnt (+ 1))
wn <- readIORef wcnt
expect (wn == 1) ("bracket cleanup ran " <> show wn <> " times, want 1")
-- 5. malloc variant: finalizeForeignPtr runs the release deterministically
fcnt <- newIORef (0 :: Int)
fp <- mallocArrowArrayImport
withForeignPtr fp $ \rawp ->
exportArrowArray rawp emptyArrowArray (modifyIORef' fcnt (+ 1))
finalizeForeignPtr fp
fn <- readIORef fcnt
expect (fn == 1) ("finalizer cleanup ran " <> show fn <> " times, want 1")
-- 6. exported stream: drive it as a consumer, entirely in-process
streamCnt <- newIORef (0 :: Int)
gnCalls <- newIORef (0 :: Int)
stp <- callocBytes (sizeOf (undefined :: ArrowArrayStream))
exportArrowArrayStream stp
ArrowStreamProducer
{ producerGetSchema = \o -> do
exportArrowSchema o emptyArrowSchema (pure ())
pure 0
, producerGetNext = \o -> do
i <- readIORef gnCalls
modifyIORef' gnCalls (+ 1)
if i < 2
then exportArrowArray o emptyArrowArray { arrayLength = fromIntegral (i + 1) } (pure ())
else poke' o -- end-of-stream: zeroed struct, null release
pure 0
, producerGetLastError = pure nullPtr
, producerCleanup = modifyIORef' streamCnt (+ 1)
}
st <- peek stp
-- schema call
withArrowSchemaImport $ \so -> do
rc <- callStreamGetSchema (streamGetSchema st) stp so
expect (rc == 0) "get_schema returned nonzero"
-- two batches then end-of-stream
lens <- mapM (const (nextLen st stp)) [1 :: Int, 2, 3]
expect (lens == [Just 1, Just 2, Nothing]) ("stream batches: " <> show lens)
releaseArrowArrayStream stp
releaseArrowArrayStream stp -- double release no-op
stn <- readIORef streamCnt
expect (stn == 1) ("stream cleanup ran " <> show stn <> " times, want 1")
free stp
-- 7. DLPack: produce and consume entirely in-process
dcnt <- newIORef (0 :: Int)
dbuf <- mallocBytes (6 * 8) :: IO (Ptr Double)
mapM_ (uncurry (pokeElemOff dbuf)) (zip [0 ..] [1 .. 6])
mt <-
newManagedTensor
(DLDataType kDLFloat 64 1)
[2, 3]
(castPtr dbuf)
dlpackFlagReadOnly
(free dbuf >> modifyIORef' dcnt (+ 1))
dvals <- consumeManagedTensor mt $ \m -> do
expect (dlverMajor (mtvVersion m) == dlpackMajorVersion) "tensor version major"
expect (mtvFlags m == dlpackFlagReadOnly) "tensor flags lost"
let t = mtvTensor m
expect (dltNDim t == 2) ("tensor ndim: " <> show (dltNDim t))
dsh <- mapM (peekElemOff (dltShape t)) [0, 1]
expect (dsh == [2, 3]) ("tensor shape: " <> show dsh)
expect (dltStrides t == nullPtr) "strides not null (compact expected)"
mapM (peekElemOff (castPtr (dltData t) :: Ptr Double)) [0 .. 5]
expect (dvals == [1 .. 6]) ("tensor values: " <> show dvals)
dn <- readIORef dcnt
expect (dn == 1) ("tensor cleanup ran " <> show dn <> " times, want 1")
-- 8. a throwing cleanup must not escape into the (foreign) caller:
-- the struct is still marked released and the process survives
ecnt <- newIORef (0 :: Int)
ep <- callocBytes (sizeOf (undefined :: ArrowArray))
exportArrowArray ep emptyArrowArray
(modifyIORef' ecnt (+ 1) >> ioError (userError "cleanup boom"))
releaseArrowArray ep
e1 <- peek ep
expect (arrayRelease e1 == nullFunPtr) "throwing cleanup: release not nulled"
releaseArrowArray ep -- and release stays a no-op afterwards
en <- readIORef ecnt
expect (en == 1) ("throwing cleanup ran " <> show en <> " times, want 1")
free ep
-- 9. a throwing stream callback surfaces as errno EIO, not a crash
tp <- callocBytes (sizeOf (undefined :: ArrowArrayStream))
exportArrowArrayStream tp
ArrowStreamProducer
{ producerGetSchema = \_ -> ioError (userError "schema boom")
, producerGetNext = \_ -> ioError (userError "next boom")
, producerGetLastError = pure nullPtr
, producerCleanup = pure ()
}
tst <- peek tp
trc <- withArrowSchemaImport (callStreamGetSchema (streamGetSchema tst) tp)
expect (trc == 5) ("throwing get_schema returned " <> show trc <> ", want 5 (EIO)")
releaseArrowArrayStream tp
free tp
-- 10. leak gate: 10k full export/release cycles must not grow the live
-- Haskell heap (a leaked StablePtr or cleanup closure would). C-side
-- malloc leaks are invisible here — that is the publish-stage valgrind
-- lane's job.
statsOn <- getRTSStatsEnabled
expect statsOn "RTS stats disabled - test suite must be built with -with-rtsopts=-T"
performMajorGC
live0 <- gcdetails_live_bytes . gc <$> getRTSStats
forM_ [1 :: Int .. 10000] $ \_ ->
withArrowArrayImport $ \lp ->
exportArrowArray lp emptyArrowArray (pure ())
performMajorGC
live1 <- gcdetails_live_bytes . gc <$> getRTSStats
let grownKiB = (fromIntegral live1 - fromIntegral live0) `div` 1024 :: Integer
expect (grownKiB < 1024)
("live heap grew " <> show grownKiB <> " KiB over 10k cycles (leak)")
putStrLn "keel-abi: managed-layer lifecycle tests passed (10 scenarios)"
where
poke' o = poke o emptyArrowArray
nextLen st stp = withArrowArrayImport $ \ao -> do
rc <- callStreamGetNext (streamGetNext st) stp ao
expect (rc == 0) "get_next returned nonzero"
a <- peek ao
pure $
if arrayRelease a == nullFunPtr
then Nothing
else Just (arrayLength a)