This is an automated email from the ASF dual-hosted git repository. rmaucher pushed a commit to branch 9.0.x in repository https://gitbox.apache.org/repos/asf/tomcat.git
commit af07f659e3c4344fa3047d37e2a0ce0678b04a33 Author: opencode <[email protected]> AuthorDate: Sat Oct 17 00:37:12 2026 +0200 Make TransformationFactory thread-safe for concurrent registration TransformationFactory is a JVM-wide singleton. Its builders map was a plain HashMap mutated by the public registerExtension() method, which third parties (e.g. TCK tests installing custom transformations) may call at any time, while create() is invoked on every WebSocket handshake from request threads and getInstalledExtensionNames()/ getInstalledExtensions() from client application threads. A concurrent put racing get/iteration on a HashMap can lose entries or throw ConcurrentModificationException from the handshake path. Use a ConcurrentHashMap so registration and reads are safe without external coordination. A test exercising concurrent registration and handshake-style reads reproduces the ConcurrentModificationException on the previous HashMap implementation. --- .../tomcat/websocket/TransformationFactory.java | 11 +- .../websocket/TestTransformationFactory.java | 127 +++++++++++++++++++++ 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/java/org/apache/tomcat/websocket/TransformationFactory.java b/java/org/apache/tomcat/websocket/TransformationFactory.java index 123a4a6c7e..87e0e41442 100644 --- a/java/org/apache/tomcat/websocket/TransformationFactory.java +++ b/java/org/apache/tomcat/websocket/TransformationFactory.java @@ -16,9 +16,9 @@ */ package org.apache.tomcat.websocket; -import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import javax.websocket.Extension; @@ -33,7 +33,11 @@ public class TransformationFactory { private static final TransformationFactory factory = new TransformationFactory(); - private final Map<String,TransformationBuilder> builders = new HashMap<>(); + /* + * Concurrent map since registerExtension() may be called at any time by an application while create() and the + * getInstalled*() methods are invoked concurrently by threads processing handshakes or by client applications. + */ + private final Map<String,TransformationBuilder> builders = new ConcurrentHashMap<>(); private TransformationFactory() { @@ -76,7 +80,8 @@ public class TransformationFactory { /** - * Registers a transformation builder for the given extension name. + * Registers a transformation builder for the given extension name. This method is thread-safe and may be called + * at any time, including while handshakes are in progress. * * @param name the extension name * @param builder the transformation builder diff --git a/test/org/apache/tomcat/websocket/TestTransformationFactory.java b/test/org/apache/tomcat/websocket/TestTransformationFactory.java new file mode 100644 index 0000000000..9f7b57cc15 --- /dev/null +++ b/test/org/apache/tomcat/websocket/TestTransformationFactory.java @@ -0,0 +1,127 @@ +/* + * 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.tomcat.websocket; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import jakarta.websocket.Extension.Parameter; + +import org.junit.Assert; +import org.junit.Test; + +public class TestTransformationFactory { + + /* A single extension offer with no parameters (i.e. use the defaults). */ + private static final List<List<Parameter>> DEFAULT_OFFER = Collections.singletonList(Collections.emptyList()); + + @Test + public void testBuiltInExtensionIsAvailable() { + TransformationFactory factory = TransformationFactory.getInstance(); + Assert.assertTrue(factory.getInstalledExtensionNames().contains(PerMessageDeflate.NAME)); + Assert.assertNotNull(factory.create(PerMessageDeflate.NAME, DEFAULT_OFFER, true)); + } + + + @Test + public void testCreateUnknownExtensionReturnsNull() { + Assert.assertNull( + TransformationFactory.getInstance().create("test-unknown-extension-" + System.nanoTime(), DEFAULT_OFFER, + true)); + } + + + /* + * registerExtension() is public API that may be called at any time, while create() and the getInstalled*() methods + * are called concurrently by threads processing handshakes. Concurrent registration and reads must neither throw + * nor lose entries. + */ + @Test + public void testConcurrentRegistrationAndReads() throws Exception { + TransformationFactory factory = TransformationFactory.getInstance(); + + int writerCount = 4; + int readerCount = 4; + int extensionsPerWriter = 50; + int readerIterations = 2000; + + List<Throwable> failures = new CopyOnWriteArrayList<>(); + CountDownLatch startLine = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(writerCount + readerCount); + + String prefix = "test-ext-" + System.nanoTime() + "-"; + + List<String> expected = new ArrayList<>(); + for (int w = 0; w < writerCount; w++) { + for (int i = 0; i < extensionsPerWriter; i++) { + expected.add(prefix + w + '-' + i); + } + } + + for (int w = 0; w < writerCount; w++) { + final int writer = w; + executor.execute(() -> { + try { + startLine.await(); + for (int i = 0; i < extensionsPerWriter; i++) { + String name = prefix + writer + '-' + i; + factory.registerExtension(name, PerMessageDeflate.BUILDER); + } + } catch (Throwable t) { + failures.add(t); + } + }); + } + + for (int r = 0; r < readerCount; r++) { + executor.execute(() -> { + try { + startLine.await(); + for (int i = 0; i < readerIterations; i++) { + // Exercise each read path used by the handshake processing code. + factory.create(PerMessageDeflate.NAME, DEFAULT_OFFER, true); + factory.getInstalledExtensionNames(); + factory.getInstalledExtensions(); + } + } catch (Throwable t) { + failures.add(t); + } + }); + } + + startLine.countDown(); + executor.shutdown(); + Assert.assertTrue("Concurrent test did not complete in time", + executor.awaitTermination(60, TimeUnit.SECONDS)); + + Assert.assertTrue("Unexpected failures during concurrent access: " + failures, failures.isEmpty()); + + // Every registered extension must be visible once registration has completed. + for (String name : expected) { + Assert.assertTrue("Registered extension not visible: " + name, + factory.getInstalledExtensionNames().contains(name)); + Assert.assertNotNull("Create failed for registered extension: " + name, + factory.create(name, DEFAULT_OFFER, true)); + } + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
