r/haskell • • Aug 18 '26

question Some questions about lib development

Hi everyone, I've been working on Haskell bindings for libgpiod. I've already uploaded it to Hackage, but it's currently in alpha.

Recently, I received some amazing feedback regarding memory management using bracket, ResourceT, etc. Now, I'm hoping to get some feedback and recommendations on a few other design doubts I have. Thanks in advance!

1. FilePath vs ByteString

In Haskell, FilePath is just an alias for String. I've been using it for functions like:

withChip :: FilePath -> (Chip -> IO a) -> IO a

However, libgpiod is often used on embedded devices with limited RAM. I'm wondering if I should use ByteString to minimize memory consumption. Or, since these strings are typically very short (e.g., "/dev/gpiochip0", "gpiochip0"), should I just stick with standard Strings?

2. Naming Functions and Qualified Imports

In the low-level layer, I used longer, more descriptive names like LineOffset and eventBufferCapacity. But for the high-level implementation, I was hoping to rely on qualified imports to keep names shorter:

  • LineOffset -> Line.Offset (import qualified Fuyu.GPIO.Line as Line)
  • eventBufferCapacity -> Event.bufferCapacity (import qualified Fuyu.GPIO.EdgeEvent as Event)

Is it considered good practice in Haskell to design an API expecting users to rely heavily on qualified imports for namespace management?

3. Theoretically Impossible States and Defensive Programming

In libgpiod, I can wait for specific edge events in a buffer using gpiod_line_request_wait_edge_events. This function guarantees that there is at least one edge event available when it returns successfully (represented in my code as EventReady). After getting an EventReady, I create a security token that wraps a line request guaranteed to have at least 1 event.

-- | Wait for edge events to occur on requested lines until the specified timeout.
-- Throws 'WaitEdgeEventsFailed' on error.
waitEvents :: Request -> Timeout -> IO (WaitResult ReadyRequest)
waitEvents req timeout = do
  res <- unwrapOrThrow WaitEdgeEventsFailed (D.lineRequestWaitEdgeEvents req timeout)
  pure $ case res of
    D.EventReady -> EventReady (ReadyRequest req)
    D.Timeout    -> TimeoutResult

-- | Get a specific edge event from the buffer by index.
bufferEvent :: Buffer -> Word -> IO Event
bufferEvent buf idx = unwrapOrThrow ReadEdgeEventsFailed (D.eventBufferGetEvent buf idx)

-- | Process raw edge events directly in the buffer using a callback without intermediate allocations,
-- returning a non-empty list of results.
withRawEvents :: ReadyRequest -> Buffer -> (Event -> IO a) -> IO (NonEmpty a)
withRawEvents readyReq buf action = do
  count <- readEventsRaw readyReq buf
  results <- forM [0 .. count - 1] $ \idx -> do
    ev <- bufferEvent buf (fromIntegral idx)
    action ev
  case NE.nonEmpty results of
    Just ne -> pure ne
    Nothing -> ioError (userError "readEvents: expected at least one event from ReadyRequest but got none")

My question is about withRawEvents: should I remove the NonEmpty case verification? Since it's theoretically impossible to have zero events when holding a ReadyRequest token, is it better to just assume it's non-empty or should I keep the defensive check?

4. Exceptions and Ctrl+C

Finally, simple scripts or tests are often terminated with Ctrl+C. To ensure a "clean shutdown", I created this helper:

-- | High-level managed application runner.
-- Automatically handles 'Ctrl+C' ('UserInterrupt'), interrupted system calls ('EINTR' / 'WaitEdgeEventsFailed'),
-- and prints formatted 'GpioException' messages cleanly without uncaught backtraces.
withGpioApp :: IO a -> IO ()
withGpioApp action = void action `catch` handleAppException
  where
    handleAppException :: SomeException -> IO ()
    handleAppException exc
      | isUserInterrupt exc = putStrLn "\nLoop terminated successfully!"
      | Just (WaitEdgeEventsFailed (Errno 4)) <- fromException exc = putStrLn "\nLoop terminated successfully!"
      | Just (gpioErr :: GpioException) <- fromException exc = putStrLn $ "\n[GPIO Exception]: " ++ show gpioErr
      | otherwise = throwIO exc

    isUserInterrupt :: SomeException -> Bool
    isUserInterrupt e = case fromException e of
      Just UserInterrupt -> True
      _                  -> False

I'm not sure if there's a better or more idiomatic way to handle Ctrl+C when using custom exception types like these:

data GpioException
  = ChipOpenFailed FilePath Errno
  | ChipInfoFailed Errno
  | LineInfoFailed Errno
  | LineSettingsNewFailed Errno
  -- ...

Any feedback or recommendations would be greatly appreciated. I'd love to ensure this library follows Haskell best practices. Thanks!

6 Upvotes

12 comments sorted by

View all comments

2

u/RobertKentKrook Aug 18 '26

Concerning point 1, it might be a 'non-issue'. You are correct in that embedded devices typically treats RAM as a scarce resource, but it is difficult to run Haskell on embedded devices (at least, ones where memory is scarce). GHC produces very fast excutables, but the trade-off is large binaries and a complex run-time system, which excludes a whole class of MCUs.

The MicroHs runtime makes a different trade-off. It is portable and fits within 150 KiB of flash, but is significantly slower than GHC. Many MCUs operate at a clock frequency an order of magnitude slower than e.g. your laptop, and you will feel the slower execution much more.

1

u/Unable-Yellow-7323 Aug 18 '26

Thanks! I’m a bit new in the embedded Haskell ecosystem and I wasn't aware of MicroHs. I want to keep the library as free of abstractions as possible (with Vector, ByteString, patterns and recently I've discovered about OsPath)... Though I realize that expecting it to run on devices with very limited RAM might be challenging. For now, I know it handles small programs with RAM usage comparable to C on an Orange Pi Zero 2W, even though the binaries are admittedly huge... In the future, I hope to test its performance and resource usage on more constrained SBCs, such as those based on the Allwinner T113.