Environment
- Vaadin Flow 25.1.x
- Vaadin Collaboration Engine 7.0.0
- Spring Boot 4.0.x, Java 25
- Two concurrent browser sessions exercising the same collaboration topic. The exception fires on the presence-list
SET → null (user-removed) path.
Stack trace
ERROR ... - Uncaught exception or error <uuid>:
java.util.concurrent.ExecutionException: java.lang.NullPointerException:
Cannot read field "count" because "userEntry" is null
at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:124)
at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:193)
at com.vaadin.flow.server.FutureAccess.get(FutureAccess.java:63)
at com.vaadin.flow.server.VaadinService.runPendingAccessTasks(VaadinService.java:2343)
at com.vaadin.flow.server.VaadinSession.unlock(VaadinSession.java:790)
at com.vaadin.flow.server.VaadinService.requestEnd(VaadinService.java:1760)
...
Caused by: java.lang.NullPointerException: Cannot read field "count" because "userEntry" is null
at com.vaadin.collaborationengine.PresenceManager.handleRemovedUser(PresenceManager.java:228)
at com.vaadin.collaborationengine.PresenceManager.onListChange(PresenceManager.java:212)
at com.vaadin.collaborationengine.TopicConnection$CollaborationListImplementation
.lambda$subscribe$5612c96f$1(TopicConnection.java:249)
at com.vaadin.collaborationengine.ExecutionQueue.runPendingCommands(ExecutionQueue.java:36)
at com.vaadin.collaborationengine.ComponentConnectionContext
.lambda$flushPendingActionsIfActive$f657904e$1(ComponentConnectionContext.java:328)
Source citation
PresenceManager.java, lines 224–232 in CE 7.0.0:
private void handleRemovedUser(UserInfo removedUser) {
UserEntry userEntry = userEntries.get(removedUser.getId()); // line 225: may return null
logUserOperation("remove", removedUser, userEntry != null); // line 226: explicitly logs "not present"
assert userEntry != null; // line 227: only with -ea
if (--userEntry.count == 0) { // line 228: NPE on userEntry.count
removeRegistration(userEntry);
userEntries.remove(removedUser.getId());
}
}
Why this fires
The intended null-guard on line 227 is a Java assert, which is disabled in production (no -ea). The author clearly anticipated this state — line 226 already passes userEntry != null as a boolean to the logging helper for the "not present" message — but the dereference on line 228 (--userEntry.count) is not actually protected.
Reproduces in two-user collaboration scenarios where the presence list emits a SET → null (onListChange → handleRemovedUser) for a user whose UserEntry is not in the local map. This can happen when:
- A user joins and leaves before this
PresenceManager instance had a chance to record the corresponding INSERT.
- A duplicate-remove event arrives after
onConnectionDeactivate() reset userEntries (or some equivalent state-loss path).
- The
ExecutionQueue replays buffered actions across a context lifecycle boundary (the upstream frame in the stack is ComponentConnectionContext.flushPendingActionsIfActive — see related report).
Whether the upstream-event source is itself surprising, the local handleRemovedUser should not NPE on a state it already knows is possible (line 226).
Suggested fix
Either replace the assert with an early return:
private void handleRemovedUser(UserInfo removedUser) {
UserEntry userEntry = userEntries.get(removedUser.getId());
logUserOperation("remove", removedUser, userEntry != null);
if (userEntry == null) {
return; // already removed, or never present in this view
}
if (--userEntry.count == 0) {
removeRegistration(userEntry);
userEntries.remove(removedUser.getId());
}
}
…or, if the assertion is meant to signal a genuine invariant violation that needs to surface in production, throw a non-NPE explicit exception (IllegalStateException with a useful message) — but silent recovery seems more appropriate given line 226 already accepts the "not present" reality.
Impact
Surfaces in whichever uncaught-exception handler the host app installs as an ExecutionException wrapping the NPE, tagged with a fresh UUID per occurrence. Concurrently breaks the presence-related state for the connection — observed downstream symptom is the same as the related session-NPE: cross-session collaboration broadcasts don't reach the remaining viewer, making any automated check that asserts cross-session propagation flaky.
Companion to the ComponentConnectionContext.flushPendingActionsIfActive session-NPE — both fire in the same rapid teardown scenarios and frequently appear in adjacent log entries.
Reproducer status
A minimal Vaadin / CE 7.0.0 reproducer was attempted with two-user PresenceManager
churn (rapid markAsPresent toggles, setPresenceHandler re-installs, and out-of-order
abrupt context closes). The defect's source-level cause is verifiable directly (line 227's
assert userEntry != null is a Java assertion, no-op in production), but the runtime gap
window is narrow:
ComponentConnectionContext.deactivateConnection fires activationHandler.accept(null)
→ PresenceManager.onConnectionDeactivate → resetEntries() clears userEntries.
- If
inbox is not empty at that point, deactivation is deferred (no immediate
inactivateIfDeactivating).
- A later
flushPendingActionsIfActive drains the inbox at line 328, firing the queued
SET → null lambda → handleRemovedUser → null lookup → NPE.
For the gap to manifest, a SET → null must be queued at the moment of deactivation,
AND the flush must arrive after onConnectionDeactivate ran but before
inactivateIfDeactivating nullified ui. In production applications with many
PresenceManager instances per teardown (e.g. one PM per topic in a multi-topic avatar
group) the alignment happens at ~25% per teardown; in a minimal scaffold the inbox tends
to drain before deactivate fires.
The bug is real and source-verified; the practical reproducer would require CE-internal
timing injection (reflection-driven delays in backgroundRunner.execute or inbox.add)
beyond what public API can express. Happy to invest more if the team has a preferred
approach for engineering such timing tests in the CE module's own suite.
Environment
SET → null(user-removed) path.Stack trace
Source citation
PresenceManager.java, lines 224–232 in CE 7.0.0:Why this fires
The intended null-guard on line 227 is a Java
assert, which is disabled in production (no-ea). The author clearly anticipated this state — line 226 already passesuserEntry != nullas a boolean to the logging helper for the "not present" message — but the dereference on line 228 (--userEntry.count) is not actually protected.Reproduces in two-user collaboration scenarios where the presence list emits a
SET → null(onListChange→handleRemovedUser) for a user whoseUserEntryis not in the local map. This can happen when:PresenceManagerinstance had a chance to record the correspondingINSERT.onConnectionDeactivate()resetuserEntries(or some equivalent state-loss path).ExecutionQueuereplays buffered actions across a context lifecycle boundary (the upstream frame in the stack isComponentConnectionContext.flushPendingActionsIfActive— see related report).Whether the upstream-event source is itself surprising, the local
handleRemovedUsershould not NPE on a state it already knows is possible (line 226).Suggested fix
Either replace the
assertwith an early return:…or, if the assertion is meant to signal a genuine invariant violation that needs to surface in production, throw a non-NPE explicit exception (
IllegalStateExceptionwith a useful message) — but silent recovery seems more appropriate given line 226 already accepts the "not present" reality.Impact
Surfaces in whichever uncaught-exception handler the host app installs as an
ExecutionExceptionwrapping the NPE, tagged with a fresh UUID per occurrence. Concurrently breaks the presence-related state for the connection — observed downstream symptom is the same as the related session-NPE: cross-session collaboration broadcasts don't reach the remaining viewer, making any automated check that asserts cross-session propagation flaky.Companion to the
ComponentConnectionContext.flushPendingActionsIfActivesession-NPE — both fire in the same rapid teardown scenarios and frequently appear in adjacent log entries.Reproducer status
A minimal Vaadin / CE 7.0.0 reproducer was attempted with two-user
PresenceManagerchurn (rapid
markAsPresenttoggles,setPresenceHandlerre-installs, and out-of-orderabrupt context closes). The defect's source-level cause is verifiable directly (line 227's
assert userEntry != nullis a Java assertion, no-op in production), but the runtime gapwindow is narrow:
ComponentConnectionContext.deactivateConnectionfiresactivationHandler.accept(null)→
PresenceManager.onConnectionDeactivate→resetEntries()clearsuserEntries.inboxis not empty at that point, deactivation is deferred (no immediateinactivateIfDeactivating).flushPendingActionsIfActivedrains the inbox at line 328, firing the queuedSET → nulllambda →handleRemovedUser→ null lookup → NPE.For the gap to manifest, a
SET → nullmust be queued at the moment of deactivation,AND the flush must arrive after
onConnectionDeactivateran but beforeinactivateIfDeactivatingnullifiedui. In production applications with manyPresenceManagerinstances per teardown (e.g. one PM per topic in a multi-topic avatargroup) the alignment happens at ~25% per teardown; in a minimal scaffold the inbox tends
to drain before deactivate fires.
The bug is real and source-verified; the practical reproducer would require CE-internal
timing injection (reflection-driven delays in
backgroundRunner.executeorinbox.add)beyond what public API can express. Happy to invest more if the team has a preferred
approach for engineering such timing tests in the CE module's own suite.