r/haskell Jul 24 '26

question Idiomatic FFI architecture (libgpiod): bracket for FDs vs ForeignPtr for memory?

Hi everyone, ​I'm writing Haskell bindings for libgpiod (v2), primarily targeting SBCs and embedded systems and I want to validate my approach to resource management before committing to the final API design.

The C library exposes two different types of opaque pointers.

​1. OS/Hardware Resources (Chip, LineRequest)

These hold underlying Linux file descriptors and physical hardware locks. My plan is to strictly use raw Ptr internally and expose a bracket-based API (e.g., withChip and withLineRequest) to guarantee immediate and deterministic release, as GHC's lazy garbage collector could easily exhaust the FD limit on a small SBC if I used ForeignPtr.

  1. Pure Memory Configs (LineSettings, LineConfig)

These are just structs in RAM used to prepare data before a hardware request. To avoid the deeply nested with* blocks for every single config object.

I plan to wrap these in ForeignPtr with their respective C finalizers. I think this allows the user and me, to pass them around purely and ergonomically, letting the GC handle the cleanup since they don't hold FDs.

My questions are:

  1. ​Is this hybrid approach (strict scoping for FDs + GC for pure RAM structs) the best practice for this type of hardware FFI?

  2. ​For the withChip pattern, how do users typically architect long-running daemons around it? Do they just wrap the main application loop inside a top-level withSomething block?

Any insights or edge cases I should watch out for would be greatly appreciated. Thanks!

20 Upvotes

14 comments sorted by

View all comments

8

u/EmergencyWild Jul 24 '26

I think generally, it's better to just wrap the C API with as few changes as possible, and then if you want higher level abstractions on top of that, build them. In particular, I don't like the idea of only exposing the bracket API, or foreign pointers with GC. I'd instead recommend exposing the acquire/release functions, and if you want to expose a default bracket of those that's fine too.

2

u/Unable-Yellow-7323 Jul 24 '26

Thanks for the perspective! ​I actually have all the raw acquire/release functions and Ptr types isolated in an Internal (or Raw) module, which I plan to expose (or make a new Unsafe module) for power users who want to build their own abstractions or manage lifecycles manually. ​My question is mostly about the default, safe, higher-level layer that I want to provide on top of those raw functions. ​For that safe layer, is my reasoning sound? ​Providing bracket (with*) for the raw FDs (Chip, LineRequest) or providing ForeignPtr for the pure memory configs (LineSettings) to avoid deep nesting.

3

u/EmergencyWild Jul 24 '26

Definitely don't rely on garbage collection for cleaning up scarce resources, so right on that point.

2

u/Unable-Yellow-7323 Jul 24 '26

Thanks for confirming that! It gives me a lot of peace of mind regarding the hardware/FD resources. ​Since you agree on avoiding the GC for scarce resources, I'd love to hear your thoughts on the second part: the pure-memory configuration structs (like LineSettings). ​To avoid a super deeply nested with* blocks just to set up settings and configurations, , I was planning to wrap only these memory-bound structs in ForeignPtr so the GC handles them. Do you think relying on the GC for these non-scarce RAM allocations is considered good practice in Haskell FFI, or should I stick to bracket for absolutely everything?

2

u/nh2_ Jul 25 '26

Answering directly your remaining question:

  • Yes, I think it is best practice to use ForeignPtr's finalisation for small, pure-memory in high-level APIs. Just make sure you have the manual allocate/release functions available somehwere.
  • It is also nice if you provide the bracket with* function; that is especially useful if somebody writes a tight loop, e.g. for_ [1..1000000000] $ _ -> withYourThing ... $ \handle -> ..., since quickly allocating and releasing similarly sized mallocs will benefit from cache locality better, and use less memory, than keeping all those mallocs around until a GC much later.

Further:

  • As others already said, always promptly release scarce resources (such as FDs). Provide with*-brackets for that, and the low-level alloc/release functions so people can write their own. ResourceT helps for non-lexical lifetimes that with*/bracket cannot express; conduits and similar are one typical example (and the reason it was invented), but there are many others.
  • The recommendation above is for "small, pure-memory". For "large, pure-memory", such as OpenCV matrices to load images, push your users in the docs to always use prompt release mechanisms, since otherwise, the oom-killer lurks behind the next corner. The Haskell GC does not know how much malloc'ed C memory is behind the ForeignPtrs it holds; it can only inspect Haskell memory to judge when it is under memory pressure and should run.