Track the event being dispatched per thread in EventObjectSupplier - #2975
Conversation
|
Since I wasn't involved in DI event handling before, I've asked AI to explain the grand design around the current problem. Here is the analysis: e4 DI event handling:
|
| Component | File | Responsibility |
|---|---|---|
IEventBroker / EventBroker |
eclipse.platform.ui/bundles/org.eclipse.e4.ui.services/src/org/eclipse/e4/ui/services/internal/events/EventBroker.java:53-65 |
Producer-side convenience API. send() → eventAdmin.sendEvent() (synchronous, delivered on the caller's thread), post() → eventAdmin.postEvent() (asynchronous, delivered on EventAdmin's own thread). Wraps the payload into an Event under the key org.eclipse.e4.data (= EventObjectSupplier.DATA). |
UIEventPublisher |
eclipse.platform.ui/bundles/org.eclipse.e4.ui.workbench/src/org/eclipse/e4/ui/internal/workbench/UIEventPublisher.java:61 |
EMF adapter on the application model. Every model change calls eventManager.send(topic, argMap), i.e. synchronously on whatever thread touched the model. This is the main source of UIEvents.* traffic. |
OSGi EventAdmin |
framework | Routes an Event to every registered EventHandler service whose event.topics match. For sendEvent it does so in the caller's stack. |
EventObjectSupplier |
eclipse.platform/runtime/bundles/org.eclipse.e4.core.di.extensions.supplier/src/org/eclipse/e4/core/di/internal/extensions/EventObjectSupplier.java |
The @EventTopic ExtendedObjectSupplier. Two jobs: (a) on injection, register one EventHandler (DIEventHandler) per {requestor, topic} and remember the ServiceRegistration; (b) when that handler fires, hand the event to the injector. |
UIEventObjectSupplier |
eclipse.platform.ui/bundles/org.eclipse.e4.ui.di/src/org/eclipse/e4/ui/internal/di/UIEventObjectSupplier.java |
The same for @UIEventTopic, except that execute() is pushed onto the UI thread. |
Requestor / MethodRequestor |
eclipse.platform/runtime/bundles/org.eclipse.e4.core.di/src/org/eclipse/e4/core/internal/di/Requestor.java:158, MethodRequestor.java:40-58 |
Represents one injected method or field. resolveArguments() → InjectorImpl.resolveArguments(this, ...); the values land in actualArgs. execute() only replays actualArgs – it does not resolve anything. |
InjectorImpl |
.../org/eclipse/e4/core/internal/di/InjectorImpl.java:516-520 |
For each parameter, finds the supplier by qualifier and calls extendedSupplier.get(descriptor, requestor, track, group) inline. |
ProviderHelper |
.../org/eclipse/e4/core/internal/di/osgi/ProviderHelper.java:59-95 |
Looks the supplier up as an OSGi service once and caches it in a static map – one supplier instance for the whole framework, shared by every context, every requestor and every thread. It also performs injector.inject(supplier, objectSupplier) here, which is how UIEventObjectSupplier.uiSync ever gets a value. |
One delivery, end to end
- Someone changes the model, or calls
broker.send(topic, data)→EventAdmin.sendEventon thread T. - EventAdmin calls
DIEventHandler.handleEvent(event)– still on T. - The handler makes the event "current" (
addCurrentEvent), then callsrequestor.resolveArguments(false)– still on T. InjectorImplwalks the parameters, sees the@EventTopicqualifier, gets the (singleton) supplier and callsget(...)– still on T.get()looks up the current event for that topic and returns theEventor itsDATApayload; the value is stored inrequestor.actualArgs.- The handler withdraws the event (
removeCurrentEvent) and callsrequestor.execute(), which invokes the target method with the already resolvedactualArgs.
broker.send(topic, data) thread T
└─ EventAdmin.sendEvent
└─ DIEventHandler.handleEvent
├─ addCurrentEvent(topic, event) ── publish
├─ requestor.resolveArguments(false)
│ └─ InjectorImpl.resolveArguments
│ └─ EventObjectSupplier.get( @EventTopic ) ── read
├─ removeCurrentEvent(topic) ── withdraw
└─ requestor.execute() // replays actualArgs
(UIEventObjectSupplier: uiSync.syncExec(requestor::execute))
So currentEvents is nothing but a side channel to smuggle one value through the injector, which has no API to pass a value down into a supplier. Publish, read and withdraw all happen inside a single synchronous call stack.
Why ThreadLocal is safe – and necessary
The safety argument is exactly that invariant:
- Same thread. Step 3 → 4 → 5 is one uninterrupted stack on thread T.
InjectorImplnever hands off to a job, an executor or aDisplay; it callsget()directly. So whatever T publishes, T is the one that reads it. - Nobody else has a legitimate interest in that value. A
get()call on another thread that is not dispatching must returnNOT_A_VALUE: the argument stays unresolved and the injector skips the handler. Sharing the value across threads was never a feature, it was the bug. execute()needs no event.MethodRequestor.execute()usesactualArgs, soUIEventObjectSuppliercan legitimately push only that part to the UI thread viasyncExec– the thread local is already read (and withdrawn) by then.- The supplier is a singleton (
ProviderHelper's static cache). That is precisely why a plain field was wrong: all deliveries of all topics on all threads shared one map, so concurrentsendEvents – a model change on one thread, a job on another – stomped on each other and producednull, a foreign event, or nothing at all. - Nesting is the one same-thread complication: a handler may send another event of the same topic while it is being injected. That is what the
Dequeper topic handles; previously the inner delivery destroyed the outer one. try/finallyaroundresolveArguments, pluscurrentEvents.remove()once the map empties, keep a failed injection from leaving a stale event behind and keep no state on event delivery threads.
The guard rail
This design is only correct as long as a subclass resolves arguments on the delivering thread. UIEventObjectSupplier does – only execute is moved. That assumption is what the Javadoc on currentEvents records: if someone ever wrapped resolveArguments itself in a syncExec or a Job, the value would have to travel with the requestor instead.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The bundle version must be incremented and deterministic concurrency regression coverage added.
Review effort: Balanced
Findings: 1
Open (2)
What changed in this PR
Fixes concurrent and re-entrant event injection in EventObjectSupplier.
Changes:
- Tracks active events per thread and topic using stacks.
- Ensures cleanup after argument-resolution failures.
- Adds null-safe event and qualifier handling.
| File | Description |
|---|---|
EventObjectSupplier.java |
Isolates event dispatch state and improves cleanup and null handling. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
This pull request changes some projects for the first time in this development cycle. An additional commit containing all the necessary changes was pushed to the top of this PR's branch. To obtain these changes (for example if you want to push more changes) either fetch from your fork or apply the git patch. Git patchFurther information are available in Common Build Issues - Missing version increments. |
EventObjectSupplier published the event being delivered in a single
instance-wide map keyed by topic, and DIEventHandler.handleEvent added
it, resolved the requestor's arguments and removed it again around each
delivery. EventAdmin.sendEvent() delivers on the caller's thread, so
several threads can run that sequence for the same topic concurrently
and interfere with each other:
- get() did a check-then-act (containsKey, then get) on that map without
holding its monitor. When another thread removed the entry in between,
get() returned null although its contract allows only the event or
IInjector.NOT_A_VALUE. null counts as resolved, so handlers were
invoked with a null event, for example
java.lang.NullPointerException: Cannot invoke
"org.osgi.service.event.Event.getProperty(String)"
because "event" is null
at ...perspectiveswitcher.PerspectiveSwitcher.handleLabelEvent
On the path where the requested type is not Event, get() dereferenced
the removed entry and threw the NPE inside the supplier itself.
- One thread's removal could drop the event another thread had just
published, so that handler was silently never invoked.
- One thread's event could overwrite the event another thread was about
to resolve, so that handler was invoked with a foreign event.
The event is published, resolved and withdrawn by one and the same
thread, so it is now kept in a ThreadLocal and no longer visible to
other threads. A stack per topic keeps the outer event current when a
handler sends another event of the same topic while being injected, and
the removal is done in a finally block so a failing resolution no longer
leaves a stale event behind.
Also read the current event once instead of three times, compare the
desired class null-safely, since getDesiredClass() may return null for
types that are neither a Class nor a ParameterizedType, and return null
from getTopic() when the descriptor carries no @EventTopic qualifier
instead of throwing.
EventObjectSupplierRaceTest covers the above. Each delivery is run
through the handler the supplier creates for a requestor, so that
publishing, resolving and withdrawing happen in the same order as in
production, and the requestor asks the supplier for its argument while
its arguments are being resolved, which is what InjectorImpl does. The
descriptors are read from the parameters of an annotated template
method, so the supplier resolves the topic from a real @EventTopic
qualifier. The interleavings are forced with latches instead of with
concurrent load, so a regression fails reliably rather than
occasionally: one test parks a delivery on another thread while this
thread runs a complete delivery of the same topic, and then checks that
neither of the two saw anything of the other; another one runs a
complete nested delivery of the same topic while the outer arguments are
being resolved; a third lets the resolution fail and checks that the
next delivery still gets its own event. The remaining tests cover the
payload of a parameter that is not an Event, a topic that is not being
delivered, a parameter without an @EventTopic qualifier and a requestor
that has become invalid. The test needs no running framework, it neither
subscribes nor uses an EventAdmin. It is registered in CoreTestSuite.
Fixes eclipse-platform#2974
Assisted-by: Copilot with Claude Opus 5
902d305 to
7aeae0e
Compare
|
Please ask your AI agents to generate concise Javadoc and update the PR. We do not want stuff like this in our code: |
|
UIEventObjectSupplier still has the old pattern. Please also update it. |
Old pattern of what exactly? UIEventObjectSupplier extends EventObjectSupplier, and |
Can you point what exactly is not concise in the referenced Javadoc? I would it explains pretty good what the test is about, especially there is zero documentation on whole DI project. |
If you ask your AI engine to make it concise you get for example: Adding a lot fo Javadoc (AI Slop) only leads to people not reading it. |
True, forget this feedback. |
I'm with you if it would be a typical AI generated "getter like" javadoc explaining three times what some "get" method is doing. In this particular case test class javadoc helps reader to understand complex system context which is not documented anywhere, so I assume it can also help others. The first part of the comment explains the related design details and the rest of the comment explains how the test addresses the complex behavior of the system. The proposed shorter version is not providing enough details to understand the problem and the added test. |
|
I will not block this PR due to over long AI generated Javadoc, but please get another opinion from another committer before committing this. @iloveeclipse if you have the option in your tooling switch for future contributions to Opus 5.5 or the latest Astra. Opus 5.0 is "famous" for generating AI slot in comments. Opus 5.5 is also much cheaper in terms of token usage and better in code quality. |
@eclipse-platform/eclipse-platform-committers : I've asked Dirk for a review (he seem to be involved in DI before), but he is not answering. Any candidates for a review here? Problem: Fix: |
|
For what it's worth, I think for user-facing API documentation should most definitely take into consideration being concise and avoiding redundancy. For developer/contributor/committer facing documentation, especially in a test, a little bit more verbiage to explain what the test exactly is doing seems helpful and more acceptable. Details about why a thread local is being used on the private variable also seem useful. Overall the changes look sound and I trust @iloveeclipse's good judgement that they are sound. |
|
Sorry for the late reply. I looked into the changes, but I really can't say anything about the changes in detail. It sounds reasonable, the multithreading seems to be addressed before, but looks like not good enough. And to be honest, I was more familiar with the whole topic a while ago. Since you added tests and nothing seems to be broken with your changes, I believe it is fine. |
No need to sorry and thanks for the reply, I'm busy as well and hardly can answer on all mails flying around. I will merge now, should there be any regressions, we should find & fix them early enough. |


EventObjectSupplier published the event being delivered in a single instance-wide map keyed by topic, and DIEventHandler.handleEvent added it, resolved the requestor's arguments and removed it again around each delivery. EventAdmin.sendEvent() delivers on the caller's thread, so several threads can run that sequence for the same topic concurrently and interfere with each other:
The event is published, resolved and withdrawn by one and the same thread, so it is now kept in a ThreadLocal and no longer visible to other threads. A stack per topic keeps the outer event current when a handler sends another event of the same topic while being injected, and the removal is done in a finally block so a failing resolution no longer leaves a stale event behind.
Also read the current event once instead of three times, compare the desired class null-safely, since getDesiredClass() may return null for types that are neither a Class nor a ParameterizedType, and return null from getTopic() when the descriptor carries no @EventTopic qualifier instead of throwing.
Fixes #2974 Assisted-by: Copilot with Claude Opus 5