LibGpiod V1: `time_t` modelled as C `long` breaks edge events on 32-bit platforms with 64-bit `time_t`
- Dominant language
- C#
- Stars
- 2.4k
- Forks
- 630
- Avg merge
- 11d 3h
- Merged PRs (30d)
- 2
Description
**Describe the bug**
On 32-bit platforms whose libgpiod is built with 64-bit `time_t`, the V1 `LibGpiodDriver`
passes an **8-byte** `struct timespec` where the library expects **16**, and reads
`struct gpiod_line_event` into a **12-byte** managed struct where the native one is larger.
Three consequences:
1. **Memory corruption.** `gpiod_line_event_read` writes past the end of the managed struct. On
the tested armv7 ABI that is 24 bytes into 12, so 12 bytes of overrun; the exact overrun is
ABI-dependent (on ABIs where `__u64` aligns to 4 the native struct is 20 bytes).
2. **Events are misclassified.** `event_type` sits at offset 16 in the native struct but the
managed struct reads offset 8 ??? i.e. `tv_nsec`. Since
`eventResult.event_type == 1 ? PinEventTypes.Rising : PinEventTypes.Falling` compares against
a nanosecond value, virtually every event is reported as `Falling`, and a callback registered
for `PinEventTypes.Rising` only may never fire at all.
3. **The 50 ms edge-wait timeout never fires**, so the detection loop never re-checks
`IsCancellationRequested` and `LibGpiodDriverEventHandler.Dispose()`'s
`_task.GetAwaiter().GetResult()` blocks forever ??? **disposing a controller or pin with a
`ValueChanged` callback registered never returns.**
The event fd does still become readable, which masks the timeout problem and is why this
presents as a disposal bug rather than an event bug.
Consequences 1 and 3 are measured (below). Consequence 2 follows from the struct offsets; our
own handler ignored `args.ChangeType`, so we did not observe it directly.
**Steps to reproduce**
```csharp
using System.Device.Gpio;
using System.Device.Gpio.Drivers;
// Any edge-capable line. Nothing needs to be wired up, and no edge needs to occur.
int chip = args.Length > 0 ? int.Parse(args[0]) : 0;
int line = args.Length > 1 ? int.Parse(args[1]) : 0;
var controller = new GpioController(new LibGpiodDriver(chip));
controller.OpenPin(line, PinMode.Input);
controller.RegisterCallbackForPinValueChangedEvent(
line, PinEventTypes.Rising | PinEventTypes.Falling, (_, _) => { });
Console.WriteLine("callback registered; disposing...");
controller.Dispose();
Console.WriteLine("disposed - not reached on an affected platform");
```
Published with `dotnet publish -r linux-arm --self-contained` and run on a 32-bit ARM target
whose libgpiod is built with `_TIME_BITS=64`. Removing only the
`RegisterCallbackForPinValueChangedEvent` line makes it exit normally.
**Expected behavior**
`Dispose()` returns, the process exits, and the GPIO line request is released.
**Actual behavior**
`Dispose()` never returns. Observed output, bounded by an external 20 s `timeout -s KILL`:
```
callback registered; disposing...
exit=137 elapsed=20s
```
`disposed` is never printed. The process consumes no CPU while stuck and only SIGKILL ends it.
The kernel releases the line when the process dies, so nothing leaks afterwards.
**Versions used**
- `System.Device.Gpio` **4.2.0**. The relevant code is unchanged on `main`.
- `Iot.Device.Bindings` ??? not used.
- Build machine `dotnet --info` (cross-publishing to `linux-arm`):
```
.NET SDK:
Version: 9.0.316
Commit: 687e73dff2
MSBuild version: 17.14.43+2a0eb78b3
Runtime Environment:
OS Name: Windows
OS Version: 10.0.26100
RID: win-x64
```
- Run machine: the app is self-contained, so it carries the .NET runtime ??? but **not** the
native system dependencies, and it is precisely the target's libc and libgpiod that determine
this bug. Target details: `armv7l`, Linux 6.6.48, glibc 2.39, **libgpiod 1.6.4**
(`libgpiod.so.2.2.2`) built with 64-bit `time_t`.
---
**Root cause**
`src/System.Device.Gpio/Interop/Unix/libgpiod/V1/Interop.libgpiod.cs`
- **:17** `using NativeLong = System.IntPtr;` ??? correct for C `long`, but `tv_sec` is a
**`time_t`**, which `_TIME_BITS=64` decouples from `long` (armv7: `long` 4, `time_t` 8)
- **:235** `TimeSpec { NativeLong TvSec; NativeLong TvNsec; }` ??? 8 bytes, native 16
(`int64 tv_sec; int32 tv_nsec; int32 pad`)
- **:229** `GpioLineEvent { TimeSpec ts; int event_type; }` ??? 12 bytes, native 24 on armv7,
which also moves `event_type` from offset 8 to offset 16
- passed at **:196** / **:205**, used only by `LibGpiodDriverEventHandler.cs` (timeout built at
:54, wait at :60, read at :81)
Read as a 64-bit value, the two 4-byte fields make `tv_sec` = `50_000_000 << 32` (~2.1e17 s),
and `tv_nsec` comes from memory past the struct ??? so the timeout outcome is non-deterministic: a
valid value gives a practically infinite wait, an invalid one gives `EINVAL`, which is not
`EINTR` and so faults the detection task.
**Affected platforms**
Any 32-bit target whose libgpiod is built with 64-bit `time_t`:
- **musl ??? 1.2** (2020) ??? all 32-bit archs, so Alpine and OpenWRT
- **Debian armel/armhf ??? trixie**
- **Yocto/OpenEmbedded ??? nanbield** ??? `time64.inc` appends `-D_TIME_BITS=64` for `arm`
Not affected: 64-bit, where `sizeof(long) == sizeof(time_t)` so the alias is accidentally
correct; and 32-bit without `_TIME_BITS=64`.
**Why the obvious fix is wrong**
Widening the managed struct to `{ long TvSec; int TvNsec; int Padding; }` is right on 32-bit
time64, and accidentally right on 64-bit little-endian ??? but on **32-bit without time64** the
callee reads `tv_nsec` from the high half of `TvSec` = 0, giving a **zero timeout and a
busy-spin**, silently reintroducing #1539 (fixed by #1567). It is also wrong on big-endian. So a
fix has to be conditional on the *native library's* `time_t` width, not on the platform: glibc
exports both `clock_gettime` and `__clock_gettime64`, and a P/Invoke binds the former
regardless, so nothing about the runtime or libc reveals how libgpiod was compiled.
**Suggested direction**
Stop passing `timespec`. `gpiod_line_event_get_fd` is part of the libgpiod API, so it
is available across the whole 1.x range the driver supports. The driver could poll that fd from
managed code, alongside a cancellation fd, and read the kernel's record directly:
```c
struct gpioevent_data { __u64 timestamp; __u32 id; };
```
Its **field offsets are stable** ??? `timestamp` at 0 (8 bytes), `id` at 8 (4 bytes) ??? but its
**total size is 12 or 16 depending on alignment**, which is one of the things GPIO uAPI v2 was
introduced to clean up. So: read up to 16 bytes in one call, accept a return of either 12 or 16,
and parse only the first 12 in native byte order.
That removes both affected structs, makes cancellation immediate instead of joining a native
wait, drops the 20 Hz polling loop (a better answer to #1539), and makes the hardware timestamp
available (#1660).
Alternatives considered, including a smaller hotfix that detects the ABI at runtime
**Behavioural ABI probe.** There is no declarative API to ask libgpiod which `time_t` it was
built with, but it can be detected safely with a single 16-byte probe on a line that has already
been requested for events (which the driver has at that point):
```
offset 0..7 = 00 00 00 00 00 00 00 00
offset 8..15 = FF FF FF FF FF FF FF FF
```
- A **time32** library reads `{ tv_sec = 0, tv_nsec = 0 }` ??? a valid zero timeout ??? and returns
immediately with 0 or 1.
- A **time64** library reads `tv_sec = 0` and a negative `tv_nsec`, and returns `-1`/`EINVAL`,
which is the documented `ppoll()` behaviour for an out-of-range timeout.
Cache that result, then: use the matching layout for `gpiod_line_event_wait`; always offer a
24-byte buffer to `gpiod_line_event_read`; and read `event_type` at offset 8 for time32 or
offset 16 for time64. That is a viable minimal hotfix without touching the kernel uAPI, though
the fd-based direction above is still preferable structurally ??? no probe, immediate
cancellation, no polling.
**Prefer `LibGpiodV2Driver` where libgpiod 2.x is installed** ??? its wait takes an `int64`
nanosecond timeout, so it has no such struct. Does not help images shipping only 1.x.
Confirming the size mismatch ??? no GPIO hardware needed
On an affected image, an undefined `__ppoll64` import shows libgpiod was built against 64-bit
`time_t` headers, so its `timespec` is 16 bytes:
```sh
readelf -Ws /usr/lib/libgpiod.so.2 | grep -E 'ppoll|time64'
```
Equivalently, compile a `sizeof` probe 32-bit with the flag:
```sh
gcc -m32 -D_TIME_BITS=64 -D_FILE_OFFSET_BITS=64 t.c -o t && ./t
# void*=4 long=4 time_t=8 struct timespec=16
# ^^^^^^^^^^^^^^^^^^^^^^^^ long != time_t, so the alias is wrong for tv_sec
```
On a 64-bit host the same probe prints `long=8 time_t=8 struct timespec=16` ??? the two coincide,
which is why this never shows up there.
```c
#include
#include
int main(void) {
printf("void*=%zu long=%zu time_t=%zu struct timespec=%zu\n",
sizeof(void *), sizeof(long), sizeof(time_t), sizeof(struct timespec));
return 0;
}
```
Isolating it to the timeout struct ??? Python only, no .NET
Calls `gpiod_line_event_wait` directly, asking for 50 ms on a quiet line, once with the layout
the driver sends and once with the time64 layout. Needs a gpiochip line; `gpio-sim` is enough.
```python
import ctypes, struct, signal, sys, time
CHIP, LINE = (int(sys.argv[1]), int(sys.argv[2])) if len(sys.argv) > 2 else (0, 0)
lib = ctypes.CDLL("libgpiod.so.2", use_errno=True)
lib.gpiod_chip_open_by_number.restype = lib.gpiod_chip_get_line.restype = ctypes.c_void_p
lib.gpiod_chip_get_line.argtypes = [ctypes.c_void_p, ctypes.c_uint]
lib.gpiod_line_request_both_edges_events.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
lib.gpiod_line_event_wait.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
line = lib.gpiod_chip_get_line(lib.gpiod_chip_open_by_number(CHIP), LINE)
if lib.gpiod_line_request_both_edges_events(line, b"t") < 0:
sys.exit(f"cannot request chip{CHIP} line{LINE}: errno {ctypes.get_errno()}")
signal.signal(signal.SIGALRM, lambda *a: (_ for _ in ()).throw(TimeoutError()))
for label, buf in [("8-byte as shipped", struct.pack("
What we can and cannot test
32-bit ARM with time64 only ??? not x64, arm64, or 32-bit without time64. So we are deliberately
not proposing a patch, since the obvious one regresses a configuration we cannot verify.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with src/System.Device.Gpio/Interop/Unix/libgpiod/V1/Interop.libgpiod.cs and LibGpiodDriverEventHandler.cs, tracing the timeout and event-read entry points at the lines identified in the report. Compare the proposed file-descriptor approach with the ABI-probe alternative, then reproduce on a 32-bit time64 target or gpio-sim. Done means correct edge classification and a bounded Dispose() on affected systems without regressing 32-bit time32 targets.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp, linux
- Domain
- embedded-iot
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100