packages feed

keel-dyn-0.1.0.0: src-windows/Keel/Dyn/Platform.hs

-- | Windows implementation: LoadLibraryExW \/ GetProcAddress \/ FreeLibrary.
--
-- All imports come from kernel32, which GHC's mingw toolchain links by
-- default, so this module needs no headers, no import libraries and no
-- build-time configuration.
module Keel.Dyn.Platform
  ( Library
  , libraryPath
  , DynError (..)
  , loadLibrary
  , loadLibraryGlobal
  , closeLibrary
  , withLibrary
  , resolveSym
  , resolveOptional
  , addSearchDir
  ) where

import Control.Concurrent (rtsSupportsBoundThreads, runInBoundThread)
import Control.Exception (Exception, finally, mask)
import Control.Monad (void)
import Data.Bits ((.|.))
import Data.Word (Word32)
import Foreign.C.String (CString, CWString, withCString, withCWString)
import Foreign.C.Types (CInt (..))
import Foreign.Ptr (FunPtr, Ptr, castFunPtr, nullFunPtr, nullPtr)
import System.Environment (lookupEnv)
import System.FilePath (isAbsolute, splitSearchPath, (</>))

type HMODULE = Ptr ()

-- | A loaded shared library. Constructor deliberately not exported.
data Library = Library
  { libHandle :: !HMODULE
  , libraryPath :: !FilePath
    -- ^ The path\/name the library was requested as.
  }

-- | Failure modes of loading and symbol resolution.
data DynError
  = LibraryNotFound FilePath String
    -- ^ Library could not be loaded; the 'String' carries OS detail.
  | SymbolNotFound FilePath String
    -- ^ The named symbol is absent from the named library.
  deriving (Eq, Show)

instance Exception DynError

-- LoadLibrary runs the target's DllMain and pages the image in from
-- disk, and FreeLibrary runs DllMain again — both can block for a long
-- time, so they are imported safe: a slow load stalls only its own
-- thread, not every Haskell thread on the capability (the unix package
-- marks dlopen/dlclose safe for the same reason). GetProcAddress,
-- GetLastError and AddDllDirectory are cheap lookups and stay unsafe.
foreign import ccall safe "LoadLibraryExW"
  c_LoadLibraryExW :: CWString -> Ptr () -> Word32 -> IO HMODULE

foreign import ccall unsafe "GetProcAddress"
  c_GetProcAddress :: HMODULE -> CString -> IO (FunPtr ())

foreign import ccall safe "FreeLibrary"
  c_FreeLibrary :: HMODULE -> IO CInt

foreign import ccall unsafe "GetLastError"
  c_GetLastError :: IO Word32

foreign import ccall unsafe "AddDllDirectory"
  c_AddDllDirectory :: CWString -> IO (Ptr ())

-- LOAD_LIBRARY_SEARCH_DEFAULT_DIRS: application dir + System32 + directories
-- registered through AddDllDirectory. Deliberately excludes CWD (a classic
-- DLL-planting hazard); PATH is only reached via the explicit walk below,
-- which never consults CWD either.
searchDefaultDirs :: Word32
searchDefaultDirs = 0x00001000

-- LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR: also resolve the loaded DLL's own
-- dependencies from the directory the DLL itself lives in. Only legal when
-- the requested path is fully qualified; essential for multi-DLL packages
-- (onnxruntime.dll finds its provider DLLs next to itself).
searchDllLoadDir :: Word32
searchDllLoadDir = 0x00000100

-- | Load a shared library by bare name or path. Search order is documented
-- in "Keel.Dyn". The current directory is never searched: the primary
-- lookup uses the safe default-directories set, and the @PATH@ fallback
-- for bare names walks the @PATH@ entries explicitly instead of handing
-- the name to the legacy loader search (whose order includes CWD).
loadLibrary :: FilePath -> IO (Either DynError Library)
loadLibrary path = inBound $ withCWString path $ \wpath -> do
  let flags
        | isAbsolute path = searchDllLoadDir .|. searchDefaultDirs
        | otherwise = searchDefaultDirs
  hEx <- c_LoadLibraryExW wpath nullPtr flags
  if hEx /= nullPtr
    then pure (Right (Library hEx path))
    else do
      -- read the primary attempt's error before any further calls
      code <- c_GetLastError
      h <- if isAbsolute path then pure nullPtr else tryPath path
      pure $
        if h == nullPtr
          then Left (LibraryNotFound path ("Win32 error " <> show code))
          else Right (Library h path)

-- Walk PATH ourselves: each candidate is loaded by absolute path (with
-- own-directory dependency resolution), relative PATH entries are
-- skipped, and CWD is never consulted.
tryPath :: FilePath -> IO HMODULE
tryPath name = do
  dirs <- maybe [] splitSearchPath <$> lookupEnv "PATH"
  go (filter isAbsolute dirs)
  where
    go [] = pure nullPtr
    go (d : ds) = do
      h <- withCWString (d </> name) $ \w ->
        c_LoadLibraryExW w nullPtr (searchDllLoadDir .|. searchDefaultDirs)
      if h /= nullPtr then pure h else go ds

-- GetLastError is only meaningful on the OS thread that made the failing
-- call, and an unbound Haskell thread may migrate between two foreign
-- calls; a bound thread pins the whole load-and-diagnose sequence to one
-- OS thread. A non-threaded RTS has a single OS thread already.
inBound :: IO a -> IO a
inBound = if rtsSupportsBoundThreads then runInBoundThread else id

-- | On Windows this is identical to 'loadLibrary': PE imports are
-- resolved per-module from the DLL's import table, so there is no
-- POSIX-style global symbol namespace to opt into.
loadLibraryGlobal :: FilePath -> IO (Either DynError Library)
loadLibraryGlobal = loadLibrary

-- | Release the OS handle, best-effort: a failed FreeLibrary is ignored —
-- there is no recovery, and 'withLibrary' must not let a cleanup failure
-- replace the action's own exception. 'FunPtr's resolved from this
-- 'Library' must not be called afterwards.
closeLibrary :: Library -> IO ()
closeLibrary = void . c_FreeLibrary . libHandle

-- | 'loadLibrary' \/ 'closeLibrary' bracket, async-exception-safe: the
-- window between a successful load and the cleanup registration is
-- masked, so a timeout cannot leak the handle.
withLibrary :: FilePath -> (Library -> IO a) -> IO (Either DynError a)
withLibrary path act = mask $ \restore -> do
  r <- loadLibrary path
  case r of
    Left e -> pure (Left e)
    Right lib -> restore (Right <$> act lib) `finally` closeLibrary lib

-- | Resolve an exported symbol to a 'FunPtr', to be invoked through a
-- @foreign import ccall \"dynamic\"@ wrapper. The result type is the
-- caller's unchecked claim about the C signature.
resolveSym :: Library -> String -> IO (Either DynError (FunPtr a))
resolveSym lib name = withCString name $ \cname -> do
  fp <- c_GetProcAddress (libHandle lib) cname
  if fp == nullFunPtr
    then pure (Left (SymbolNotFound (libraryPath lib) name))
    else pure (Right (castFunPtr fp))

-- | 'resolveSym' flattened to 'Maybe', for symbols whose absence is an
-- expected, degradable condition rather than an error.
resolveOptional :: Library -> String -> IO (Maybe (FunPtr a))
resolveOptional lib name = either (const Nothing) Just <$> resolveSym lib name

-- | Register an extra DLL search directory for subsequent 'loadLibrary'
-- calls. The directory must be an absolute path (AddDllDirectory refuses
-- relative ones). Returns 'False' if the OS refused it.
addSearchDir :: FilePath -> IO Bool
addSearchDir dir = withCWString dir $ \wdir -> do
  cookie <- c_AddDllDirectory wdir
  pure (cookie /= nullPtr)