Repository navigation
LibGpiodV2: unchecked null from gpiod_edge_event_buffer_get_event aborts the process (gpiod_edge_event_copy: Assertion 'event' failed) #2600
Description
A long-running service that watches a single input pin via
RegisterCallbackForPinValueChangedEvent is killed several times a day by a native abort()
raised inside libgpiod:
Garden.Pi: edge-event.c:41: gpiod_edge_event_copy: Assertion `event' failed.
systemd[1]: garden.service: Main process exited, code=killed, status=6/ABRT
systemd[1]: garden.service: Failed with result 'signal'.
Because this is a native assertion, the process dies outright — it cannot be caught or handled
from managed code, so there is no way for an application to survive it.
Notably, this happens without any real edge events occurring. In one two-hour window the
application logged zero pin changes on the only watched line, yet the process still aborted. So
this is not a matter of a bouncing input producing a flood of events; the crash occurs on a
wakeup that delivers no event.
Analysis
EdgeEventBuffer.GetEvent passes the pointer returned by gpiod_edge_event_buffer_get_event
directly into gpiod_edge_event_copy without checking it for null:
src/System.Device.Gpio/Interop/Unix/libgpiod/V2/Proxies/EdgeEventBuffer.cs
public EdgeEvent GetEvent(ulong index)
{
return CallLibgpiod(() =>
{
using EdgeEventNotFreeable edgeEventHandle = LibgpiodV2.gpiod_edge_event_buffer_get_event(Handle, index);
// no null check here
EdgeEventSafeHandle edgeEventCopyHandle = LibgpiodV2.gpiod_edge_event_copy(edgeEventHandle);
return new EdgeEvent(edgeEventCopyHandle);
});
}gpiod_edge_event_buffer_get_event returns NULL when the requested index is not populated in
the buffer, and gpiod_edge_event_copy starts with assert(event) — so a null pointer here is
an immediate abort() rather than a recoverable error.
The caller is the observer loop in
src/System.Device.Gpio/System/Device/Gpio/Drivers/LibGpiodV2EventObserver.cs:
int waitResult = request.WaitEdgeEventsRespectfully(WaitEdgeEventsTimeout);
// ... timeout / interrupt handling ...
int numberOfReadEvents = request.ReadEdgeEvents(edgeEventBuffer);
for (int i = 0; i < numberOfReadEvents; i++)
{
EdgeEvent edgeEvent = edgeEventBuffer.GetEvent((ulong)i);
HandleEdgeEvent(edgeEvent);
}The loop bound comes from gpiod_line_request_read_edge_events (via LineRequest.ReadEdgeEvents,
called with edgeEventBuffer.Capacity, which is 10 by default from
LibGpiodProxyFactory.CreateEdgeEventBuffer). The observed crash implies that the count returned
there can exceed the number of events actually retrievable from the buffer, at which point
GetEvent receives null and the process aborts.
Note also that this runs on a background task whose catch only logs to Console.WriteLine — but
that is moot here, since a native assertion never surfaces as a managed exception.
Expected behaviour
A null event should not be able to terminate the host process. Either:
EdgeEventBuffer.GetEventnull-checks the handle and throws aGpiodException(or returns
null and lets the observer skip the entry), and/or- the observer loop bounds its iteration by
gpiod_edge_event_buffer_get_num_events(already
exposed asEdgeEventBuffer.GetNumEvents()) rather than by the value returned from
ReadEdgeEvents.
(1) alone is enough to turn a process-killing abort into a handleable error, and looks like the
minimal safe fix.
Environment
| Hardware | Raspberry Pi 2 Model B Rev 1.1 (armv7l) |
| OS | Raspbian GNU/Linux 13 (trixie) |
| libgpiod | 2.2.1-2+rpi1+deb13u1 (libgpiod3, libgpiod.so.3.1.1) |
| .NET | 10.0, self-contained linux-arm |
| System.Device.Gpio | 4.2.0 (latest release) |
| Driver | LibGpiodV2Driver (auto-selected) |
Reproduction
- On a Debian trixie / libgpiod 2.2.x system, open one pin as input and register an edge
callback:
GpioController controller = new();
controller.OpenPin(21, PinMode.Input);
controller.RegisterCallbackForPinValueChangedEvent(
21,
PinEventTypes.Rising | PinEventTypes.Falling,
(sender, args) => Console.WriteLine($"pin {args.PinNumber} -> {args.ChangeType}"));- Leave the process running. No input activity is required.
- Within minutes to hours the process aborts with the assertion above.
gpioinfo for the watched line while running:
line 21: "GPIO21" input bias=disabled edges=both consumer="C#-LibGpiodV2Driver-<pid>"
Impact
There is no application-side workaround: the abort cannot be caught, and 4.2.0 is the newest
release. The only options are to stop using edge-event watching entirely (poll instead), or to
pin the V1 driver — which on trixie needs libgpiod2 alongside libgpiod3 and has its own open
crash reports. We have currently had to disable input reading altogether.
Activity
pgrawehr commentedon Aug 27, 2026
@kai-melchior You mention some kind of wakeup. Does the Raspberry Pi enter some kind of sleep state before this happens? I have seen instances where I waited very long for a line but nothing bad happened.
kai-melchior commentedon Sep 7, 2026
@pgrawehr No sleep state. The Pi was only idle and waiting for incoming changes
raffaeler commentedon Sep 7, 2026
@kai-melchior In this case I do not understand how could the handle become invalid.
If the machine is always awake and the process does not close the handle, it should remain valid.
What am I missing?
pgrawehr commentedon Sep 17, 2026
[Triage] I will try to reproduce this when I find time. Still confuses me why the handle becomes invalid just while waiting.
wolfgang-knobloch commentedon Sep 23, 2026
We hit the same abort on an armv7l gateway (self-contained
linux-arm, libgpiod 2.x /libgpiod.so.3, System.Device.Gpio 3.2.0). I believe the root cause is an ABI mismatch in the V2 P/Invoke declaration on 32-bit ARM, not an invalid handle.libgpiod declares:
Interop.libgpiod.cs(V2) declares:C
unsigned longis 32-bit on ARM32 (ILP32), butulongis always 64-bit. Under AAPCS a 64-bit argument must go into an even register pair, so the runtime passesbufferinr0andindexinr2:r3, skippingr1. libgpiod reads its 32-bitindexfromr1, which contains whatever was left there. That index is almost always>= num_events, sogpiod_edge_event_buffer_get_eventreturnsNULLandgpiod_edge_event_copyhitsassert(event).This fits the report:
unsigned longis 64-bit, so the declaration is correct.GetEventbefore any callback can run, so nothing is ever logged.Suggested fix: use
nuintfor Cunsigned long/size_tparameters and return values in the V2 binding. That coversgpiod_edge_event_buffer_get_event(index) and theunsigned longreturns ofgpiod_edge_event_get_global_seqno/gpiod_edge_event_get_line_seqno, which currently return a garbage upper half on ARM32.size_tparameters such asgpiod_edge_event_buffer_new(capacity)andgpiod_line_request_read_edge_events(max_events)are declared asint, which works on 32-bit but should also benuintfor correctness on 64-bit. A null check inEdgeEventBuffer.GetEvent, as proposed above, is still a good safety net.pgrawehr commentedon Oct 1, 2026
@wolfgang-knobloch Please review #2610.