Skip to content

Fix NPE and cross-workspace corruption when running the same statistic concurrently - #3309

Merged
mbastian merged 3 commits into
masterfrom
fix/statistics-concurrent-workspace-run
Sep 6, 2026
Merged

mbastian merged 3 commits into
masterfrom
fix/statistics-concurrent-workspace-run

Conversation

@mbastian

@mbastian mbastian commented Sep 6, 2026 •

Copy link
Copy Markdown
Member

Description

Running the same statistic (e.g. Statistical Inference) in two workspaces at the same time throws:

java.lang.NullPointerException: Cannot invoke "org.gephi.statistics.plugin.StatisticalInferenceClustering.getDescriptionLength()" because "this.descriptionLength" is null
	at org.gephi.ui.statistics.plugin.StatisticalInferenceClusteringUI.getValue(StatisticalInferenceClusteringUI.java:81)
	at org.gephi.desktop.statistics.StatisticsModelUIImpl.addResult(StatisticsModelUIImpl.java:84)
	at org.gephi.desktop.statistics.StatisticsControllerUIImpl$1.taskFinished(StatisticsControllerUIImpl.java:141)

Root cause: every StatisticsUI implementation is registered as a @ServiceProvider singleton shared by all workspaces. setup(statistics) stashes the running Statistics instance in an instance field, getValue() reads it, unsetup() nulls it. Two workspaces running the same statistics class concurrently race on that shared field: whichever finishes first calls unsetup() and nulls it out from under the other, which crashes on getValue() (or, without the crash, could silently read/report the wrong workspace's value). Most of the ~16 StatisticsUI implementations follow this same pattern, so the bug isn't specific to Statistical Inference.

An earlier version of this PR serialized runs per statistics class with a Semaphore. A review caught that this didn't actually close the bug: StatisticsFrontEnd's pre-execution setup()/unsetup() calls around a statistic's settings panel (e.g. Modularity) happen outside that lock, so the same race — and for statistics with no settings panel, a redundant setup() call ahead of the lock check turned the crash into guaranteed silent data corruption instead of fixing it.

This version fixes the actual root cause instead: StatisticsUI.getValue() is deprecated and a new getValue(Statistics) is added as a default method (falling back to the deprecated getValue() for any implementation that hasn't migrated, so third-party StatisticsUI plugins keep compiling and working unchanged, just without the fix). Every first-party implementation (16 classes) and their one caller (StatisticsModelUIImpl.addResult) are migrated to read the result from the passed-in Statistics instance directly instead of a shared field. With no more shared mutable state involved, concurrent runs of the same statistic across workspaces just work — no locking, no rejection dialog, no behavior change for the happy path.

Checklist

  • Merged with master beforehand

Added tests?

  • 👍 yes

Added to documentation?

  • 👍 README.md
  • 👍 API Changes
  • 👍 Additional documentation in docs
  • 👍 Relevant code documentation
  • 🙅 no, because they aren't needed

🤖 Generated with Claude Code

mbastian and others added 3 commits September 6, 2026 15:07
…c concurrently

Every StatisticsUI implementation is a singleton shared by all workspaces
and stashes the running Statistics instance in a field between setup() and
unsetup(). Running the same statistic (e.g. Statistical Inference) in two
workspaces at the same time let one workspace's completion null out or
overwrite the field while the other was still reading it, causing an NPE
in getValue() (or silently attributing the wrong workspace's values).

Serialize execution per statistics class in StatisticsControllerUIImpl:
a second concurrent run of the same statistics class is now rejected with
a warning dialog instead of racing, while different statistics classes and
concurrent runs in general remain fully parallel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… SPI

A code review of the previous commit found the semaphore approach still
left every statistic with a settings panel exposed to the same race (the
lock never covered StatisticsFrontEnd's pre-execute setup()/unsetup()
calls), and for statistics without one it turned the crash into silent
data corruption instead of fixing it.

Fix the actual root cause instead: StatisticsUI.getValue() had to read a
Statistics instance stashed in a field by setup(), which is unsafe since
every StatisticsUI implementation is a singleton shared by all workspaces.
Add StatisticsUI.getValue(Statistics) as a default method (delegating to
the deprecated getValue() for any implementation that hasn't migrated,
so third-party StatisticsUI plugins keep compiling and working unchanged)
and migrate every first-party implementation plus their only caller
(StatisticsModelUIImpl.addResult) to it. The result no longer depends on
any shared mutable state, so concurrent runs of the same statistic across
workspaces just work, with no locking or rejection needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@mbastian mbastian added this to the 0.11.3 milestone Sep 6, 2026
@mbastian
mbastian merged commit 1b4ca6a into master Sep 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant