This is an automated email from the ASF dual-hosted git repository.
mridulpathak pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git
The following commit(s) were added to refs/heads/trunk by this push:
new 4fcb1619b7 Fixed: Service dispatcher local context race causes
intermittent NullPointerException (OFBIZ-13508)
4fcb1619b7 is described below
commit 4fcb1619b7898bc4bc59fef6ca06b04ea333f9ba
Author: Mridul Pathak <[email protected]>
AuthorDate: Sun Aug 30 18:36:39 2026 +0530
Fixed: Service dispatcher local context race causes intermittent
NullPointerException (OFBIZ-13508)
ServiceDispatcher#localContext was a plain HashMap read without
synchronization by getLocalContext(), getLocalDispatcher() and
containsContext() while register()/deregister() mutated it under a synchronized
lock. Under concurrent dispatcher registration, such as many test suites
constructing LocalDispatchers against the same delegator, an unsynchronized
read could transiently return null for a key that was, and still is,
registered, surfacing as a NullPointerException in checkAuth() a [...]
---
.../apache/ofbiz/service/ServiceDispatcher.java | 5 +-
.../ServiceDispatcherConcurrencyTest.groovy | 192 +++++++++++++++++++++
2 files changed, 196 insertions(+), 1 deletion(-)
diff --git
a/framework/service/src/main/java/org/apache/ofbiz/service/ServiceDispatcher.java
b/framework/service/src/main/java/org/apache/ofbiz/service/ServiceDispatcher.java
index 9192918cee..75dcdb92e7 100644
---
a/framework/service/src/main/java/org/apache/ofbiz/service/ServiceDispatcher.java
+++
b/framework/service/src/main/java/org/apache/ofbiz/service/ServiceDispatcher.java
@@ -86,7 +86,10 @@ public final class ServiceDispatcher {
private Delegator delegator = null;
private GenericEngineFactory factory = null;
private Security security = null;
- private Map<String, DispatchContext> localContext = new HashMap<>();
+ // ConcurrentHashMap: register()/deregister() are synchronized, but
getLocalContext(),
+ // getLocalDispatcher() and containsContext() read this on every service
call without
+ // locking, so the map itself must tolerate a reader racing a concurrent
writer.
+ private Map<String, DispatchContext> localContext = new
ConcurrentHashMap<>();
private Map<String, List<GenericServiceCallback>> callbacks = new
HashMap<>();
private JobManager jm = null;
private JmsListenerFactory jlf = null;
diff --git
a/framework/service/src/test/groovy/org/apache/ofbiz/service/ServiceDispatcherConcurrencyTest.groovy
b/framework/service/src/test/groovy/org/apache/ofbiz/service/ServiceDispatcherConcurrencyTest.groovy
new file mode 100644
index 0000000000..8dd00760bb
--- /dev/null
+++
b/framework/service/src/test/groovy/org/apache/ofbiz/service/ServiceDispatcherConcurrencyTest.groovy
@@ -0,0 +1,192 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+
*******************************************************************************/
+package org.apache.ofbiz.service
+
+import org.apache.ofbiz.base.start.Config
+import org.apache.ofbiz.base.start.Start
+import org.apache.ofbiz.base.util.Debug
+import org.apache.ofbiz.entity.Delegator
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Timeout
+import org.mockito.Mockito
+
+import java.lang.reflect.Constructor
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Regression test for the ServiceDispatcher#localContext race:
register()/deregister() are
+ * synchronized, but getLocalContext(), getLocalDispatcher() and
containsContext() read the same
+ * plain HashMap without locking, so a reader can transiently see a
null/corrupted result for a
+ * key that both was, and still is, registered.
+ *
+ * Log levels are disabled around the race: first-time ServiceDispatcher
construction under this
+ * repo's full multi-module test classpath logs heavily enough (parsing every
component's service
+ * config) to overwhelm the appender otherwise. Every mock LocalDispatcher
shares one Delegator
+ * mock so DispatchContext's own internal service-model cache actually hits
after the first
+ * lookup - without it, every registration re-triggers a full, expensive
reactor-wide scan.
+ */
+class ServiceDispatcherConcurrencyTest {
+
+ private static final int WRITER_THREADS = 8
+ private static final int KEYS_PER_WRITER = 500
+ private static final int READER_THREADS = 4
+ // Backstop only, in case the stopReaders flag is somehow missed.
+ private static final int MAX_READER_ITERATIONS = 2_000_000
+
+ private Config originalStartConfig
+
+ @BeforeEach
+ void stubStartConfigIfMissing() {
+ System.setProperty('ofbiz.home', System.getProperty('user.dir'))
+ originalStartConfig = Start.getInstance().getConfig()
+ // ServiceDispatcher's constructor needs a non-null Start config; stub
one only if missing,
+ // and always restore it below since it's a JVM-wide singleton.
+ if (originalStartConfig == null) {
+ Config stubConfig = Mockito.mock(Config)
+ Mockito.when(stubConfig.getPortOffset()).thenReturn(0)
+ Start.getInstance().setConfig(stubConfig)
+ }
+ }
+
+ @AfterEach
+ void restoreStartConfig() {
+ Start.getInstance().setConfig(originalStartConfig)
+ }
+
+ @Test
+ // SAME_THREAD (default): SEPARATE_THREAD runs the test body on the common
ForkJoinPool, which
+ // doesn't mix well with this test's own blocking (await/get/join).
Bounded internally instead.
+ @Timeout(30)
+ void getLocalContextToleratesConcurrentRegister() {
+ boolean infoWasOn = Debug.get(Debug.INFO)
+ boolean warningWasOn = Debug.get(Debug.WARNING)
+ Debug.set(Debug.INFO, false)
+ Debug.set(Debug.WARNING, false)
+ try {
+ ServiceDispatcher sd = newDispatcher()
+ // Shared so every DispatchContext below resolves to the same
cache key (see class doc).
+ Delegator sharedDelegator = Mockito.mock(Delegator)
+
Mockito.when(sharedDelegator.getDelegatorBaseName()).thenReturn('default')
+ LocalDispatcher anchorDispatcher = Mockito.mock(LocalDispatcher)
+
Mockito.when(anchorDispatcher.getDelegator()).thenReturn(sharedDelegator)
+ LocalDispatcher writerDispatcher = Mockito.mock(LocalDispatcher)
+
Mockito.when(writerDispatcher.getDelegator()).thenReturn(sharedDelegator)
+ String anchorName =
"anchorRegressionTestDispatcher${UUID.randomUUID()}"
+ DispatchContext anchorContext = new DispatchContext(anchorName,
this.class.classLoader, anchorDispatcher)
+
+ // Registered once, up front, and never deregistered - anything
reading this exact key
+ // should always see it, for the whole life of this test.
+ sd.register(anchorContext)
+
+ CountDownLatch startLatch = new CountDownLatch(1)
+ AtomicBoolean stopReaders = new AtomicBoolean(false)
+ AtomicReference<Throwable> readerFailure = new AtomicReference<>()
+ AtomicReference<Throwable> writerFailure = new AtomicReference<>()
+
+ List<Thread> readers = (1..READER_THREADS).collect {
+ Thread reader = new Thread({
+ startLatch.await()
+ int iterations = 0
+ while (!stopReaders.get() && iterations++ <
MAX_READER_ITERATIONS) {
+ try {
+ if (sd.getLocalContext(anchorName) == null) {
+ throw new
AssertionError("getLocalContext(${anchorName}) returned null while the anchor
was still registered")
+ }
+ if (sd.getLocalDispatcher(anchorName) == null) {
+ throw new
AssertionError("getLocalDispatcher(${anchorName}) returned null while the
anchor was still registered")
+ }
+ if (!sd.containsContext(anchorName)) {
+ throw new
AssertionError("containsContext(${anchorName}) returned false while the anchor
was still registered")
+ }
+ // A corrupted HashMap can throw here too, not just
return null.
+ } catch (Throwable t) { // codenarc-disable
CatchThrowable
+ readerFailure.compareAndSet(null, t)
+ return
+ }
+ }
+ } as Runnable)
+ reader.daemon = true
+ reader
+ }
+ readers.each { it.start() }
+
+ ThreadFactory daemonThreadFactory = { Runnable r ->
+ Thread t = Executors.defaultThreadFactory().newThread(r)
+ t.daemon = true
+ t
+ } as ThreadFactory
+ ExecutorService writers =
Executors.newFixedThreadPool(WRITER_THREADS, daemonThreadFactory)
+ try {
+ List<Future<?>> futures = (1..WRITER_THREADS).collect { int
writerIndex ->
+ writers.submit({
+ startLatch.await()
+ for (int j = 0; j < KEYS_PER_WRITER; j++) {
+ try {
+ String key =
"raceRegressionTestDispatcher-${writerIndex}-${j}"
+ sd.register(new DispatchContext(key,
this.class.classLoader, writerDispatcher))
+ } catch (Throwable t) { // codenarc-disable
CatchThrowable
+ writerFailure.compareAndSet(null, t)
+ return
+ }
+ }
+ } as Runnable)
+ }
+
+ startLatch.countDown()
+ futures.each { it.get(10, TimeUnit.SECONDS) }
+ } finally {
+ writers.shutdownNow()
+ writers.awaitTermination(5, TimeUnit.SECONDS)
+ stopReaders.set(true)
+ readers.each { it.join(5000) }
+ }
+
+ if (writerFailure.get() != null) {
+ Assertions.fail("register() threw under concurrent load:
${writerFailure.get()}", writerFailure.get())
+ }
+ if (readerFailure.get() != null) {
+
Assertions.fail('getLocalContext()/getLocalDispatcher()/containsContext()
failed while racing concurrent register(): '
+ + "${readerFailure.get()}", readerFailure.get())
+ }
+
+ Assertions.assertTrue(sd.containsContext(anchorName), 'Anchor
context should still be registered at the end of the test')
+ } finally {
+ Debug.set(Debug.INFO, infoWasOn)
+ Debug.set(Debug.WARNING, warningWasOn)
+ }
+ }
+
+ private static ServiceDispatcher newDispatcher() {
+ Constructor<ServiceDispatcher> ctor =
ServiceDispatcher.getDeclaredConstructor(Delegator)
+ ctor.setAccessible(true)
+ // null delegator: skips DB/JobManager setup, no entity engine or demo
data needed.
+ return ctor.newInstance((Delegator) null)
+ }
+
+}