Skip to content

NullPointerException in PresenceManager.handleRemovedUser when removing a user not present in the local userEntries map (CE 7.0.0) #140

Description

@enver-haase

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 (onListChangehandleRemovedUser) 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:

  1. ComponentConnectionContext.deactivateConnection fires activationHandler.accept(null)
    PresenceManager.onConnectionDeactivateresetEntries() clears userEntries.
  2. If inbox is not empty at that point, deactivation is deferred (no immediate
    inactivateIfDeactivating).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions