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!