Fix libgpiod V1 edge events on 32-bit platforms with 64-bit time_t - #2605
wietsejorissen wants to merge 6 commits into
Conversation
On 32-bit platforms whose libgpiod is built with _TIME_BITS=64, the V1 driver 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. TimeSpec models tv_sec with NativeLong (= IntPtr), but tv_sec is a time_t, which _TIME_BITS=64 decouples from long: on armv7 long is 4 bytes and time_t is 8. The consequences are that the 50 ms edge-wait timeout never fires, so the detection loop never re-checks cancellation and Dispose() blocks forever; that event_type is read at offset 8 instead of 16, so events are misclassified and a Rising-only callback never fires; and that gpiod_line_event_read writes past the end of the managed struct on every event. Rather than model struct timespec per platform, which would require knowing how the native library was compiled, stop passing it. Take the line's event descriptor with gpiod_line_event_get_fd, poll() it alongside a self-pipe used for cancellation, and read the kernel's fixed-width gpioevent_data record directly. poll() takes an int timeout, unlike ppoll(), so nothing on this path depends on _TIME_BITS. Field offsets in gpioevent_data are the same on every architecture; only the total size varies with alignment, so a whole record of either 12 or 16 bytes is accepted. gpiod_line_event_get_fd was added in libgpiod 1.0.1. Where it is absent the previous gpiod_line_event_wait loop is kept as a fallback, which is correct on those releases: they predate the 64-bit time_t transition, so the managed struct timespec matches there. Side effects of the new path: cancellation is immediate instead of waiting for the next timeout, and the 20 Hz wakeup per subscribed line is gone (a 1 s backstop remains, purely to bound disposal if a wake is ever lost). Measured on a ConnectCore MP157 (armv7l, glibc 2.39, libgpiod 1.6.4 built with 64-bit time_t): before, disposing a pin with a callback never returned and edge classification was rising=0/falling=34 over 34 button edges; after, disposal returns in under 100 ms and classification is correct over 250+ edges across several runs. Not validated on x64, arm64, or 32-bit without time64; those need CI or hardware we do not have. Refs dotnet#2604 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Store Interop.pipe2.cs with a UTF-8 BOM. Without it StyleCop SA1412 fails a Release build. Refuse the legacy wait path where its ABI cannot be known safe. The previous comment argued the pre-1.0.1 path was correct because those releases predate 64-bit time_t, but that infers an ABI property from a release date: an old libgpiod source rebuilt against a _TIME_BITS=64 libc has no gpiod_line_event_get_fd and a 16-byte struct timespec, which would reintroduce exactly the corruption this change fixes. The legacy path is now used only where sizeof(long) == sizeof(time_t) can be relied on, i.e. 64-bit; a 32-bit process without the event descriptor gets PlatformNotSupportedException naming the libgpiod 1.0.1 requirement. Make every Dispose() caller wait for the detection task. Only the first caller signals cancellation and closes the self-pipe, but a second caller returning early could let a containing driver release the line handle while the task was still polling its descriptor. Leave the legacy path otherwise untouched, reverting its task creation and callback invocation to keep this change scoped to the ABI fix. Clarify in comments that the 12/16-byte record size comes from the kernel uAPI's trailing alignment for the reading process, not from libgpiod's time64-dependent wrapper, and state why a failed wake write is not fatal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extract the gpioevent_data classification into TryClassifyEventRecord so it can be tested without GPIO hardware, and let the test project see internals, using the AssemblyAttribute pattern the device projects already use. LibGpiodV1EventRecordTests covers both record sizes, rising and falling ids, any other id, and rejection of partial or zero-length reads. One case pins the id to offset 8 by building a record whose timestamp bytes would read as "rising" while the real id says falling: reading the id from the wrong offset misclassifies every event, which is one of the symptoms this change fixes. LibGpiodV1DriverTests gains a hardware-gated regression test that registers a callback and asserts Dispose() returns without an edge ever arriving, which is the failure this change exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The record-parsing unit tests needed InternalsVisibleTo on the shipping library, but that assembly is strong-name signed, so a friend reference has to name the signing public key (CS1726). Hard-coding that literal could not be verified here: the key read from the published package may not be the one a local or official build signs the test assembly with, in which case it would compile and then break the friend relationship at test time. Rather than guess, the friend reference and the tests that depend on it are removed and the csproj is back to its original contents. TryClassifyEventRecord is kept as an internal seam so those tests are a small addition once the mechanism is agreed: either a friend reference expressed through whatever property the signing configuration provides, or a different testable surface. The hardware-gated test asserting Dispose() returns without an edge is retained, since it needs no access to internals. Verified with the repository's own build: build.cmd -configuration Release on src/System.Device.Gpio reports 0 warnings, 0 errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The added regression test used Task.Wait and GetAwaiter().GetResult(), which the repository's xUnit analyzers reject as errors (xUnit1031, blocking task operations in a test method), so the test project did not build. Await Task.WhenAny against a timeout instead and assert on which task finished. Awaiting the disposal task afterwards still surfaces any exception thrown on that path, so the test keeps both of the properties the blocking version had. Verified with the repository's own build: build.cmd -configuration Release on src/System.Device.Gpio.Tests/System.Device.Gpio.Tests.csproj reports 0 warnings, 0 errors. Separately, checked on the same ConnectCore MP157 that repeated subscription does not leak: 1000 register/unregister cycles on one line leave the descriptor and thread counts unchanged from cycle 250 onwards, and the controller disposes cleanly afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@dotnet-policy-service agree company="Calculus" |
|
AI GENERATED — evidence-based review of PR #2605 SummaryThe diagnosis in the PR body is verified: on 32-bit ARM with The chosen fix — Recommendation: approve with one nit. Non-blocking findings1.
|
| ExceptionResource.EventReadError => $"Error while reading pin event result{errorDisplay}", | ||
| ExceptionResource.NotListeningForEventError => $"Attempted to remove a callback for a pin that is not listening for events.", | ||
| ExceptionResource.LibGpiodNotInstalled => $"Libgpiod driver not installed. More information on: https://git.kernel.org/pub/scm/libs/libgpiod/libgpiod.git/about/", | ||
| ExceptionResource.LibGpiodVersionTooOld => $"Pin event detection on a 32-bit process requires libgpiod 1.0.1 or later, which provides gpiod_line_event_get_fd. More information on: https://git.kernel.org/pub/scm/libs/libgpiod/libgpiod.git/about/", |
There was a problem hiding this comment.
[Triage] Please verify this and fix - see AI review
There was a problem hiding this comment.
Verified against upstream history: gpiod_line_event_get_fd predates 1.0.1. I removed the version-specific claim from the code and PR/issue descriptions, and now describe support in terms of the loaded libgpiod build exporting the symbol. I also renamed the exception resource accordingly.
| /// <param name="line">GPIO line handle</param> | ||
| /// <returns>The event file descriptor, or -1 if the line was not requested for events.</returns> | ||
| /// <remarks> | ||
| /// Available since libgpiod 1.0.1. Reading event records straight from this descriptor avoids |
There was a problem hiding this comment.
Updated here as well; the remarks now describe why the descriptor is used without claiming a minimum version.
|
|
||
| private static readonly string s_consumerName = Process.GetCurrentProcess().ProcessName; | ||
|
|
||
| // gpiod_line_event_get_fd was added in libgpiod 1.0.1. Older libraries fall back to the |
There was a problem hiding this comment.
Updated here as well; the cache is now described in terms of whether the loaded build exports the symbol.
| int lineEventFd = TryGetLineEventFd(safeLineHandle); | ||
| if (lineEventFd < 0) | ||
| { | ||
| // libgpiod predating 1.0.1 has no event descriptor, leaving only the legacy wait |
There was a problem hiding this comment.
Updated here as well; the fallback explanation now refers to descriptor availability and only uses the legacy layout where it is known safe.
|
|
||
| private Task InitializeEventDetectionTask(CancellationToken token, LineHandle pinHandle) | ||
| /// <summary> | ||
| /// Returns the line event descriptor, or -1 when the installed libgpiod predates 1.0.1 and |
There was a problem hiding this comment.
Updated here as well; this now describes a missing export rather than a pre-1.0.1 library.
| } | ||
|
|
||
| /// <summary> | ||
| /// Pre-1.0.1 libgpiod path, unchanged from before the event descriptor was used. Correct on |
There was a problem hiding this comment.
Updated here as well; this now describes builds without the export and the 64-bit restriction.
| return lineEventFd; | ||
| } | ||
|
|
||
| private Task InitializeEventDetectionTask(CancellationToken token, int lineEventFd) |
There was a problem hiding this comment.
how is this related to 32/64-bitness?
There was a problem hiding this comment.
please isolate the change, if this is some issue you're seeing please send separate PR
There was a problem hiding this comment.
Agreed. I restored Task.Run so this PR preserves the existing thread model and remains scoped to the time64 fix. I have not observed a separate thread-pool starvation issue, so I am not opening a follow-up PR at this point.
|
AI GENERATED — follow-up: compatibility risk and scope Could this break existing users?Going through the concrete behaviour deltas on the paths that were already working (64-bit, and 32-bit systems where libgpiod is not built with Low / no risk
Behaviour changes worth calling out on the changelog
Net: I do not see a realistic scenario where a previously-working configuration regresses, but the "line fd errors now throw" behaviour is worth a line in the release notes. Relevance of the diff to the PR titleTitle is "Fix libgpiod V1 edge events on 32-bit platforms with 64-bit time_t." Most of the diff is a direct consequence of the fix approach and belongs:
Scope creep candidate:
Suggested action: either (a) call this out explicitly in the PR description as an intentional improvement enabled by the rewrite, or (b) drop it from this PR and file it as a separate change that can also be applied to the legacy path for consistency. Not blocking either way, but the current PR body does not mention it. Everything else in the diff traces back to the |
Fixes #2604
On a 32-bit platform whose libgpiod is built with
_TIME_BITS=64, the V1 driver passes an 8-bytestruct timespecwhere the library expects 16, and readsstruct gpiod_line_eventinto a 12-bytemanaged struct where the native one is 24.
TimeSpecmodelstv_secwithNativeLong(=IntPtr),but
tv_secis atime_t, and_TIME_BITS=64decouples the two: on armv7longis 4 bytes andtime_tis 8.Three consequences:
Dispose()on a pin with a registered callback never returns;event_typeis read at offset 8 instead of 16, so aRising-only callback never fires;gpiod_line_event_readwrites past the end of the managed struct on every event.Change
Modelling
struct timespecper platform would require knowing how the native library was compiled,which is not observable from managed code. This stops passing one instead: take the line's event
descriptor with
gpiod_line_event_get_fd,poll()it alongside a self-pipe used for cancellation,and read the kernel's fixed-width
gpioevent_datarecord directly.poll()takes aninttimeout,unlike
ppoll(), so nothing on this path depends on_TIME_BITS. Field offsets ingpioevent_dataare architecture-independent; only total size varies with trailing alignment, so a whole record of
either 12 or 16 bytes is accepted.
gpiod_line_event_get_fdexists since libgpiod 1.0.1. Where it is absent the existinggpiod_line_event_waitloop is kept, but only on 64-bit, wheresizeof(long) == sizeof(time_t)holds. A 32-bit process without the event descriptor gets
PlatformNotSupportedExceptionnaming themissing-symbol requirement, rather than reading structures whose size cannot be established.
No public API change. The V2 driver is untouched.
Behaviour differences
poll()timeout remains to bounddisposal if a wake write is ever lost; it does no work when it expires.
detection thread already existed.
Measurements
ConnectCore MP157 (armv7l, glibc 2.39, libgpiod 1.6.4 built with 64-bit
time_t), Digi EmbeddedYocto 5.0:
Dispose()on a pin with a callbackSIGKILLrising=0 falling=34Reproduces identically on .NET 8 and .NET 9.0.18;
IntPtr.Sizeis 4 in both, so the runtime's owntime64 work does not reach this.
Tests
LibGpiodV1DriverTestsgains a hardware-gated test asserting thatDispose()returns when a pin hasa registered callback and no edge arrives.
Unit tests for the record parsing that need no hardware are written but not included: they require
InternalsVisibleToon the shipping library, which is strong-name signed, and adding a friendreference also invalidates 11 existing
protected overrides inMockableGpioDriver(CS0507).TryClassifyEventRecordis left as an internal seam, so they are a small addition if you can saywhich surface you would prefer.
build.cmd -configuration Releaseon bothSystem.Device.GpioandSystem.Device.Gpio.Testsreports 0 warnings, 0 errors.
Not covered
Tested only on 32-bit Arm with 64-bit
time_t. No x64, arm64 or bookworm-armhf hardware availablehere, so those paths are unverified by me. The new path avoids
time_tby construction, but that isan argument, not a test result. Hardware availability here is temporary, so follow-up needing the
board may have to be picked up elsewhere.
Two follow-ups, described rather than filed, since neither is a regression and both look like yours
to scope / say if you want them as issues:
Dispose()called from inside a pin-value-changed callback deadlocks by joining its own detectiontask. Longstanding and platform-independent, but it sits in the code this PR reworks, so it would
be wrong to call this change deadlock-safe while it stands. Repro and a suggested direction (skip
the join when already on the detection task) available.
does, so behaviour is unchanged. Batching needs stride detection to separate the 12- and 16-byte
layouts mid-buffer.
Microsoft Reviewers: Open in CodeFlow