Skip to content

Fix ffm-plugin: 5th+ digital inputs receive no state change events#623

Merged
FDelporte merged 8 commits intodevelopfrom
copilot/fix-ffm-plugin-button-handling
Mar 28, 2026
Merged

Fix ffm-plugin: 5th+ digital inputs receive no state change events#623
FDelporte merged 8 commits intodevelopfrom
copilot/fix-ffm-plugin-button-handling

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 25, 2026

Virtual threads in EventWatcher pin to ForkJoinPool carrier threads when making blocking native poll() calls. On a Raspberry Pi 4 (4 cores = 4 carrier threads), once 4 pins are registered, subsequent pins can never be scheduled.

Changes

  • Primary fix — thread model (FFMDigitalInput.addListener): Replace Thread.ofVirtual() + Executors.newThreadPerTaskExecutor() with Thread.ofPlatform().daemon(true) + Executors.newCachedThreadPool(). Platform threads have dedicated OS threads and cannot be pinned, removing the 4-input ceiling.
// Before — virtual threads; 5th blocks indefinitely on a 4-core Pi
this.threadFactory = Thread.ofVirtual().name(...)
    .factory();
this.eventTaskProcessor = Executors.newThreadPerTaskExecutor(threadFactory);

// After — platform daemon threads; unlimited concurrent watchers
this.threadFactory = Thread.ofPlatform().name(...)
    .daemon(true).factory();
this.eventTaskProcessor = Executors.newCachedThreadPool(threadFactory);
  • Secondary fix — debounce mask (FFMDigitalInput.initialize): LineConfigAttribute.mask is an index into offsets[], not a GPIO number. Each FFMDigitalInput owns a single-line request at index 0, so the mask must be 1L, not 1L << bcm. The previous code silently disabled hardware debounce for every BCM pin > 0.
// Before — wrong: uses BCM number as bit position
attributes.add(new LineConfigAttribute(debounceAttribute, 1L << bcm));

// After — correct: bit 0 = index 0 in offsets[]
attributes.add(new LineConfigAttribute(debounceAttribute, 1L));
  • Regression test (testMoreThanFourInputsReceiveEvents): The test uses a CountDownLatch barrier to make each watcher's first poll() call block until all 5 watchers are simultaneously blocked — directly mimicking concurrent blocking native poll() syscalls. With the old virtual-thread code on a ≤4-core machine the ForkJoinPool carrier pool is exhausted by the first 4 blocking watchers; the 5th watcher is never scheduled and the test times out, proving the bug existed. With the fixed platform-daemon-thread code all 5 run on dedicated OS threads, block concurrently, and the test passes.
Original prompt

This section details on the original issue you should resolve

<issue_title>Bug in ffm-plugin</issue_title>
<issue_description>If you want to attach 8 (more than 4) buttons to the pi via digital pins, only the first 4 buttons are handled correctly. All subsequent buttons don’t deliver any stateChangedEvents.

When going back to gpiod-plugin without any further change in software and hardware, everything works as expected.

So it’s definitively a problem in the ffm-plugin.</issue_description>

<agent_instructions>Starting points for investigation:

https://github.com/Pi4J/pi4j/blob/develop/plugins/pi4j-plugin-ffm/src/main/java/com/pi4j/plugin/ffm/providers/gpio/FFMDigitalInputProviderImpl.java

https://github.com/Pi4J/pi4j/blob/develop/plugins/pi4j-plugin-ffm/src/main/java/com/pi4j/plugin/ffm/providers/gpio/FFMDigitalInput.java</agent_instructions>

Comments on the Issue (you are @copilot in this section)


📍 Connect Copilot coding agent with Jira, Azure Boards or Linear to delegate work to Copilot in one click without leaving your project management tool.

@FDelporte
Copy link
Copy Markdown
Member

@copilot if possible extend ffm-plugin unit tests to proof this error existed in the current version and is fixed after your changes.

Primary fix: Changed EventWatcher from virtual threads to platform daemon
threads to avoid carrier thread pinning when blocking native poll() is called.
Virtual threads pinned to the ForkJoinPool carrier threads (one per CPU core)
prevented 5th+ pins from running on Raspberry Pi 4 (4 cores).

Secondary fix: Corrected LineConfigAttribute debounce mask from `1L << bcm`
to `1L` (index 0 in the offsets array), per kernel GPIO documentation. This
fixes debounce being silently ignored on all BCM pins > 0.

Added regression test: testMoreThanFourInputsReceiveEvents verifies that 5+
digital inputs all receive state change events.

Co-authored-by: FDelporte <1415873+FDelporte@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Pi4J/pi4j/sessions/97e73dd8-a439-48fa-a3c1-c5758cb50f8c
Copilot AI changed the title [WIP] Fix bug in ffm-plugin for multiple button handling Fix ffm-plugin: 5th+ digital inputs receive no state change events Mar 25, 2026
Copilot AI requested a review from FDelporte March 25, 2026 12:49
…e the carrier-thread pinning bug

The mock poll() now blocks each watcher thread (using a CountDownLatch barrier)
until all numPins=5 watchers are simultaneously blocked, directly mimicking
concurrent blocking native poll() syscalls.

With the old virtual-thread code on a <=4-core machine the ForkJoinPool carrier
pool is exhausted and the 5th watcher never enters poll(), causing
allWatchersBlockingLatch.await() to time out — proving the bug existed.

With the fixed platform-daemon-thread code every watcher has its own OS thread,
all 5 block concurrently, and the test completes successfully.

Co-authored-by: FDelporte <1415873+FDelporte@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Pi4J/pi4j/sessions/d2e6d6d7-e3eb-4563-aec4-1ca7da3e2a40
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 25, 2026

@copilot if possible extend ffm-plugin unit tests to proof this error existed in the current version and is fixed after your changes.

Done in commit 210c8cb. The testMoreThanFourInputsReceiveEvents test now uses a CountDownLatch barrier to make each watcher's first poll() call block until all 5 watchers are simultaneously blocked — directly mimicking concurrent blocking native poll() syscalls.

With the old virtual-thread code on a ≤4-core machine the ForkJoinPool carrier pool is exhausted by the first 4 blocking watchers; the 5th watcher is never scheduled and allWatchersBlockingLatch.await() times out, proving the bug. With the fixed platform-daemon-thread code all 5 run on dedicated OS threads, all block concurrently, and the test passes.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • checkstyle.org
    • Triggering command: /opt/hostedtoolcache/CodeQL/2.24.3/x64/codeql/tools/linux64/java/bin/java /opt/hostedtoolcache/CodeQL/2.24.3/x64/codeql/tools/linux64/java/bin/java -jar /opt/hostedtoolcache/CodeQL/2.24.3/x64/codeql/xml/tools/xml-extractor.jar --fileList=/tmp/codeql-scratch-850ba6d10baafc1e/dbs/java/working/files-to-index504279305256686625.list --sourceArchiveDir=/tmp/codeql-scratch-850ba6d10baafc1e/dbs/java/src --outputDir=/tmp/codeql-scratch-850ba6d10baafc1e/dbs/java/trap/java (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

logger.trace("{}-{} - Adding new listener", deviceName, bcm);
if (threadFactory == null) {
this.threadFactory = Thread.ofVirtual().name(deviceName + "-event-detection-pin-", bcm)
this.threadFactory = Thread.ofPlatform().name(deviceName + "-event-detection-pin-", bcm)
Copy link
Copy Markdown
Member

@FDelporte FDelporte Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot can you explain this in more detail please? I thought the goal of virtual threads was exactly to step away from the platform thread limit? So how is this a problem that four virtual threads block the four threads on a four-core processor like the raspberry pi?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Virtual threads are designed to avoid the OS-thread-per-task limit by unmounting from their carrier thread whenever they hit a standard Java blocking call (Object.wait(), LockSupport.park(), ReentrantLock, etc.) — the carrier thread is released and can run another virtual thread. That's the happy path.

There is one critical exception: a virtual thread that calls a native method (JNI or Foreign Function) while blocking cannot be unmounted. It stays pinned to its carrier thread for the entire duration of the native call. This is documented in the JEP-444 notes as "pinning".

Native.poll() here is a foreign-function call that ultimately issues the Linux poll(2) syscall. While the kernel is sitting inside poll() waiting for an edge event, the virtual thread cannot yield — it pins a carrier thread.

The ForkJoinPool that backs virtual threads defaults its carrier-thread count to Runtime.getRuntime().availableProcessors()4 on a Raspberry Pi 4. Once 4 EventWatcher loops are each blocked inside the native poll() call, all 4 carrier threads are pinned. Any 5th (or later) EventWatcher virtual thread that needs to start its loop can never be scheduled: there are no free carrier threads and it cannot steal one because none will be released until a GPIO edge event arrives.

Platform daemon threads each own a real OS thread. Blocking in a native call is normal for an OS thread — it only blocks that one thread, and the OS can schedule all others independently. That removes the 4-input ceiling entirely.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wow, yeah, didn't realize we were using virtual threads. Makes absolute sense as i read about the pinning before.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great to know, I did not know the pinning was related to native calls. I guess that would fix the issue.

Copy link
Copy Markdown
Member

@eitch eitch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the exception of the debounce, i agree with the fix.

throw new InitializeException("Debounce value of " + debounce + " is too large");
}
var debounceAttribute = new LineAttribute(LineAttributeId.GPIO_V2_LINE_ATTR_ID_DEBOUNCE.getValue(), 0, 0, (int) debounce * 1000);
attributes.add(new LineConfigAttribute(debounceAttribute, 1L << bcm));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@IAmNickNack do you agree with copilot here? It says the following:

Secondary fix — debounce mask (FFMDigitalInput.initialize): LineConfigAttribute.mask is an index into offsets[], not a GPIO number. Each FFMDigitalInput owns a single-line request at index 0, so the mask must be 1L, not 1L << bcm. The previous code silently disabled hardware debounce for every BCM pin > 0.
// Before — wrong: uses BCM number as bit position
attributes.add(new LineConfigAttribute(debounceAttribute, 1L << bcm));

// After — correct: bit 0 = index 0 in offsets[]
attributes.add(new LineConfigAttribute(debounceAttribute, 1L));

@FDelporte
Copy link
Copy Markdown
Member

Extra smoke test shows the solution works! Will merge after some more tests.

# Conflicts:
#	pi4j-test/src/main/java/com/pi4j/test/Main.java
#	pi4j-test/src/main/java/com/pi4j/test/smoketest/FiveDigitalInputsTestCase.java
@eitch
Copy link
Copy Markdown
Member

eitch commented Mar 26, 2026

Bloody awesome!

@FDelporte FDelporte marked this pull request as ready for review March 26, 2026 15:09
Copilot AI review requested due to automatic review settings March 26, 2026 15:09
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a concurrency limitation in the FFM GPIO digital input provider where event watcher threads could become unschedulable (observed as missing state-change events on the 5th+ input on 4-core devices), and corrects hardware debounce configuration targeting.

Changes:

  • Switch FFMDigitalInput event-watcher execution from virtual threads to platform daemon threads to avoid carrier-thread pinning during blocking native poll() calls.
  • Fix debounce attribute mask application to target the requested line index (bit 0) rather than shifting by BCM number.
  • Add a unit regression test that simulates concurrent blocking poll() behavior across 5 digital inputs.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
plugins/pi4j-plugin-ffm/src/main/java/com/pi4j/plugin/ffm/providers/gpio/FFMDigitalInput.java Updates debounce mask usage and changes the event-watcher thread/executor model.
plugins/pi4j-plugin-ffm/src/test/java/com/pi4j/plugin/ffm/unit/GPIOTest.java Adds a regression test covering 5+ simultaneous digital input event watchers.
pi4j-test/src/main/java/com/pi4j/test/smoketest/FiveDigitalInputsTestCase.java Removes a stray leading line artifact.
Comments suppressed due to low confidence (2)

plugins/pi4j-plugin-ffm/src/main/java/com/pi4j/plugin/ffm/providers/gpio/FFMDigitalInput.java:145

  • The uncaughtExceptionHandler(...) configured on the ThreadFactory will not see exceptions thrown by EventWatcher because the task is submitted via ExecutorService.submit(...), which captures exceptions in the returned Future. If you want these failures logged, use execute(...) for Runnable tasks or store/inspect the Future and log ExecutionException (or wrap the runnable to catch/log).
            this.threadFactory = Thread.ofPlatform().name(deviceName + "-event-detection-pin-", bcm)
                .daemon(true)
                .uncaughtExceptionHandler(((_, e) -> logger.error(e.getMessage(), e)))
                .factory();
            this.eventTaskProcessor = Executors.newCachedThreadPool(threadFactory);
        }
        var watcher = new EventWatcher(chipFileDescriptor, PinEvent.BOTH, events -> {
            for (DetectedEvent detectedEvent : events) {
                var state = switch (detectedEvent.pinEvent()) {
                    case RISING -> DigitalState.HIGH;
                    case FALLING -> DigitalState.LOW;
                    default -> DigitalState.UNKNOWN;
                };
                this.dispatch(new DigitalStateChangeEvent<DigitalInput>(this, state));
            }
        });
        watchers.add(watcher);
        eventTaskProcessor.submit(watcher);
        logger.trace("{}-{} - New listener added", deviceName, bcm);

plugins/pi4j-plugin-ffm/src/main/java/com/pi4j/plugin/ffm/providers/gpio/FFMDigitalInput.java:146

  • addListener(...) currently creates and submits a new EventWatcher every time it is called. Multiple watcher threads polling/reading from the same line-event FD will race to consume events and can cause missed/duplicated events (and unnecessary extra threads). Consider starting exactly one watcher per FFMDigitalInput (e.g., only when the first listener/binding is added) and have it dispatch to the shared listener manager.
    public DigitalInput addListener(DigitalStateChangeListener... listener) {
        logger.trace("{}-{} - Adding new listener", deviceName, bcm);
        if (threadFactory == null) {
            this.threadFactory = Thread.ofPlatform().name(deviceName + "-event-detection-pin-", bcm)
                .daemon(true)
                .uncaughtExceptionHandler(((_, e) -> logger.error(e.getMessage(), e)))
                .factory();
            this.eventTaskProcessor = Executors.newCachedThreadPool(threadFactory);
        }
        var watcher = new EventWatcher(chipFileDescriptor, PinEvent.BOTH, events -> {
            for (DetectedEvent detectedEvent : events) {
                var state = switch (detectedEvent.pinEvent()) {
                    case RISING -> DigitalState.HIGH;
                    case FALLING -> DigitalState.LOW;
                    default -> DigitalState.UNKNOWN;
                };
                this.dispatch(new DigitalStateChangeEvent<DigitalInput>(this, state));
            }
        });
        watchers.add(watcher);
        eventTaskProcessor.submit(watcher);
        logger.trace("{}-{} - New listener added", deviceName, bcm);
        return super.addListener(listener);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +361 to +376
// Regression test for the carrier-thread-pinning bug:
//
// Before the fix, EventWatcher used virtual threads. Virtual threads that call a
// blocking native method (poll()) are *pinned* to a ForkJoinPool carrier thread
// for the duration of the call. The carrier pool size defaults to the number of
// available CPU cores (4 on a Raspberry Pi 4). Once 4 pins are listening, all
// carrier threads are pinned and the 5th+ EventWatcher can never be scheduled.
//
// This test makes each watcher's first poll() call block until ALL numPins
// watchers are simultaneously blocked, then releases them. That directly mirrors
// the real scenario: concurrent blocking native poll() calls.
//
// With the old virtual-thread code on a <=4-core machine the test would time out
// at allWatchersBlockingLatch because the 5th watcher can never enter poll().
// With the fixed platform-daemon-thread code every watcher runs on its own OS
// thread, all 5 block simultaneously, and the test completes.
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This regression test only fails with the old virtual-thread implementation on machines where the virtual-thread scheduler parallelism is <= 4 (e.g., a 4-core Raspberry Pi). On CI/dev machines with >4 cores, the old buggy code may still pass, so the test may not reliably prevent regressions. Consider forcing the processor count/scheduler parallelism for the test JVM (e.g., Surefire -XX:ActiveProcessorCount=4 or jdk.virtualThreadScheduler.parallelism=4 set before any virtual threads are created), or explicitly skip/assert based on availableProcessors() so the test outcome is deterministic.

Copilot uses AI. Check for mistakes.
Comment on lines 97 to 103
// check conversion from ms to ns
if (debounce * 1000 > Integer.MAX_VALUE) {
throw new InitializeException("Debounce value of " + debounce + " is too large");
}
var debounceAttribute = new LineAttribute(LineAttributeId.GPIO_V2_LINE_ATTR_ID_DEBOUNCE.getValue(), 0, 0, (int) debounce * 1000);
attributes.add(new LineConfigAttribute(debounceAttribute, 1L << bcm));
attributes.add(new LineConfigAttribute(debounceAttribute, 1L));
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DigitalInputConfigBuilder.debounce(...) stores debounce in microseconds, and LineAttribute expects debounce_period_us, but this code multiplies debounce by 1000 before passing it to the kernel attribute. That makes the configured hardware debounce 1000× larger than the Pi4J config value and also makes the overflow check misleading. Align the units (either pass debounce directly as microseconds, or clearly convert from milliseconds at the config boundary and update naming/docs accordingly).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@IAmNickNack @FDelporte @stefanhaustein perhaps here is our issue with debounce?

@FDelporte FDelporte merged commit 94e7292 into develop Mar 28, 2026
8 checks passed
@eitch eitch deleted the copilot/fix-ffm-plugin-button-handling branch March 28, 2026 19:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug in ffm-plugin: 5th and following inputs don't trigger event listeners

5 participants