Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions jme3-core/src/main/java/com/jme3/app/SimpleApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -307,13 +307,6 @@ public void initialize() {
simpleInitApp();
}

@Override
Copy link
Member Author

@richardTingle richardTingle Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed this because the restriction is held in a WeakHashMap so the old restrictions will age out anyway and trying to do a per thread removal of the entire scene graph would be unnecessarily slow and almost certainly pointless

public void stop(boolean waitFor) {
//noinspection AssertWithSideEffects
assert SceneGraphThreadWarden.reset();
super.stop(waitFor);
}

@Override
public void update() {
if (prof != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import com.jme3.scene.Spatial;

import java.util.Collections;
import java.util.Set;
import java.util.Map;
import java.util.WeakHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
Expand All @@ -17,6 +17,7 @@
* Only has an effect if asserts are on
* </p>
*/
@SuppressWarnings("SameReturnValue")
public class SceneGraphThreadWarden {

private static final Logger logger = Logger.getLogger(SceneGraphThreadWarden.class.getName());
Expand All @@ -34,8 +35,7 @@ public class SceneGraphThreadWarden {
assert ASSERTS_ENABLED = true;
}

public static Thread mainThread;
public static final Set<Spatial> spatialsThatAreMainThreadReserved = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>()));
public static final Map<Spatial, Thread> spatialsThatAreMainThreadReserved = Collections.synchronizedMap(new WeakHashMap<>());

/**
* Marks the given node as being reserved for the main thread.
Expand All @@ -48,12 +48,11 @@ public static boolean setup(Node rootNode){
return true;
}
Thread thisThread = Thread.currentThread();
if(mainThread != null && mainThread != thisThread ){
throw new IllegalStateException("The main thread has already been set to " + mainThread.getName() + " but now it's being set to " + Thread.currentThread().getName());
Thread existingRestriction = spatialsThatAreMainThreadReserved.get(rootNode);
if(existingRestriction != null && existingRestriction != thisThread ){
throw new IllegalStateException("The node is already restricted to " + existingRestriction.getName() + " but now it's being restricted to " + Thread.currentThread().getName());
}
mainThread = thisThread;
setTreeRestricted(rootNode);

setTreeRestricted(rootNode, thisThread);
return true; // return true so can be a "side effect" of an assert
}

Expand All @@ -71,11 +70,11 @@ public static void disableChecks(){
* Runs through the entire tree and sets the restriction state of all nodes below the given node
* @param spatial the node (and children) to set the restriction state of
*/
private static void setTreeRestricted(Spatial spatial){
spatialsThatAreMainThreadReserved.add(spatial);
private static void setTreeRestricted(Spatial spatial, Thread threadRestriction){
spatialsThatAreMainThreadReserved.put(spatial, threadRestriction);
if(spatial instanceof Node){
for(Spatial child : ((Node) spatial).getChildren()){
setTreeRestricted(child);
setTreeRestricted(child, threadRestriction);
}
}
}
Expand All @@ -99,8 +98,8 @@ public static boolean updateRequirement(Spatial spatial, Node newParent){
return true;
}

boolean shouldNowBeRestricted = newParent !=null && spatialsThatAreMainThreadReserved.contains(newParent);
boolean wasPreviouslyRestricted = spatialsThatAreMainThreadReserved.contains(spatial);
boolean shouldNowBeRestricted = newParent !=null && spatialsThatAreMainThreadReserved.containsKey(newParent);
boolean wasPreviouslyRestricted = spatialsThatAreMainThreadReserved.containsKey(spatial);

if(shouldNowBeRestricted || wasPreviouslyRestricted ){
assertOnCorrectThread(spatial);
Expand All @@ -110,17 +109,19 @@ public static boolean updateRequirement(Spatial spatial, Node newParent){
return true;
}
if(shouldNowBeRestricted){
setTreeRestricted(spatial);
setTreeRestricted(spatial, Thread.currentThread());
}else{
setTreeNotRestricted(spatial);
}

return true; // return true so can be a "side effect" of an assert
}

/**
* This is a full reset over all options and all threads
*/
public static boolean reset(){
spatialsThatAreMainThreadReserved.clear();
mainThread = null;
THREAD_WARDEN_ENABLED = !Boolean.getBoolean("nothreadwarden");
return true; // return true so can be a "side effect" of an assert
}
Expand All @@ -134,8 +135,9 @@ public static boolean assertOnCorrectThread(Spatial spatial){
if(checksDisabled()){
return true;
}
if(spatialsThatAreMainThreadReserved.contains(spatial)){
if(Thread.currentThread() != mainThread){
Thread restrictingThread = spatialsThatAreMainThreadReserved.get(spatial);
if(restrictingThread!=null){
if(Thread.currentThread() != restrictingThread){
// log as well as throw an exception because we are running in a thread, if we are in an executor service the exception
// might not make itself known until `get` is called on the future (and JME might crash before that happens).
String message = "The spatial " + spatial + " was mutated on a thread other than the main thread, was mutated on " + Thread.currentThread().getName();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
public class SceneGraphThreadWardenTest {

private static ExecutorService executorService;
private static ExecutorService executorService2;

@SuppressWarnings({"ReassignedVariable", "AssertWithSideEffects"})
@BeforeClass
Expand All @@ -42,11 +43,13 @@ public static void setupClass() {
@Before
public void setup() {
executorService = newSingleThreadDaemonExecutor();
executorService2 = newSingleThreadDaemonExecutor();
}

@After
public void tearDown() {
executorService.shutdown();
executorService2.shutdown();
SceneGraphThreadWarden.reset();
}

Expand All @@ -71,6 +74,31 @@ public void testNormalNodeMutationOnMainThread() {
rootNode.detachChild(child);
}

@Test
public void restrictingTheSameRootNodeToTwoThreadsIsIllegal() throws ExecutionException, InterruptedException {
Node rootNode = new Node("root");

executorService.submit(() -> SceneGraphThreadWarden.setup(rootNode)).get();

try {
executorService2.submit(() -> SceneGraphThreadWarden.setup(rootNode)).get();
fail("Expected an IllegalThreadSceneGraphMutation exception");
} catch (ExecutionException e) {
// This is expected - verify it's the right exception type
assertTrue("Expected IllegalStateException, got: " + e.getCause().getClass().getName(),
e.getCause() instanceof IllegalStateException);
}
}

@Test
public void restrictingTwoRootNodesIsFine(){
Node rootNode = new Node("root");
Node rootNode2 = new Node("root2");

SceneGraphThreadWarden.setup(rootNode);
SceneGraphThreadWarden.setup(rootNode2);
}

/**
* Test that node mutation on nodes not connected to the root node is fine even on a non main thread.
* <p>
Expand Down Expand Up @@ -107,7 +135,7 @@ public void testNodeMutationOnNonConnectedNodesOnNonMainThread() throws Executio
* exception.
*/
@Test
public void testAddingNodeToSceneGraphOnNonMainThread() throws InterruptedException {
public void testAddingNodeToSceneGraphOnNonMainThread() {
Node rootNode = new Node("root");
SceneGraphThreadWarden.setup(rootNode);

Expand All @@ -122,14 +150,7 @@ public void testAddingNodeToSceneGraphOnNonMainThread() throws InterruptedExcept
return null;
});

try {
illegalMutationFuture.get();
fail("Expected an IllegalThreadSceneGraphMutation exception");
} catch (ExecutionException e) {
// This is expected - verify it's the right exception type
assertTrue("Expected IllegalThreadSceneGraphMutation, got: " + e.getCause().getClass().getName(),
e.getCause() instanceof IllegalThreadSceneGraphMutation);
}
expectSceneGraphException(illegalMutationFuture);
}

/**
Expand All @@ -141,7 +162,7 @@ public void testAddingNodeToSceneGraphOnNonMainThread() throws InterruptedExcept
* </p>
*/
@Test
public void testMovingNodeAttachedToRootOnNonMainThread() throws InterruptedException {
public void testMovingNodeAttachedToRootOnNonMainThread() {
Node rootNode = new Node("root");
SceneGraphThreadWarden.setup(rootNode);

Expand All @@ -157,14 +178,7 @@ public void testMovingNodeAttachedToRootOnNonMainThread() throws InterruptedExce
return null;
});

try {
illegalMutationFuture.get();
fail("Expected an IllegalThreadSceneGraphMutation exception");
} catch (ExecutionException e) {
// This is expected - verify it's the right exception type
assertTrue("Expected IllegalThreadSceneGraphMutation, got: " + e.getCause().getClass().getName(),
e.getCause() instanceof IllegalThreadSceneGraphMutation);
}
expectSceneGraphException(illegalMutationFuture);
}

/**
Expand Down Expand Up @@ -199,7 +213,7 @@ public void testDetachmentReleasesProtection() throws ExecutionException, Interr
* then try (and fail) to make an illegal on-thread change to the grandchild.
*/
@Test
public void testAddingAChildToTheRootNodeAlsoRestrictsTheGrandChild() throws InterruptedException {
public void testAddingAChildToTheRootNodeAlsoRestrictsTheGrandChild() {
Node rootNode = new Node("root");
SceneGraphThreadWarden.setup(rootNode);

Expand All @@ -221,14 +235,7 @@ public void testAddingAChildToTheRootNodeAlsoRestrictsTheGrandChild() throws Int
return null;
});

try {
illegalMutationFuture.get();
fail("Expected an IllegalThreadSceneGraphMutation exception");
} catch (ExecutionException e) {
// This is expected - verify it's the right exception type
assertTrue("Expected IllegalThreadSceneGraphMutation, got: " + e.getCause().getClass().getName(),
e.getCause() instanceof IllegalThreadSceneGraphMutation);
}
expectSceneGraphException(illegalMutationFuture);
}

/**
Expand Down Expand Up @@ -294,7 +301,96 @@ public void testDisableChecksAllowsIllegalMutation() throws ExecutionException,
mutationFuture.get();
}

/**
* This tests that scenarios where multiple JME applications are running simultaneously within the same
* JMV. This is a weird case but does happen sometimes (e.g. in TestChooser).
* <p>
* This test tests that using two seperate executor services with their own seperate threads
* </p>
*/
@Test
public void testCanCopeWithMultipleSimultaneousRootThreads() throws ExecutionException, InterruptedException {
Node rootNodeThread1 = new Node("root1");
Node rootNodeThread2 = new Node("root2");

// Create a child node and attach it to the root node
Node childThread1 = new Node("child1");
Node childThread2 = new Node("child2");

// on two threads set up two root nodes (for two JME applications on the same VM)
Future<Void> mutationFuture1 = executorService.submit(() -> {
SceneGraphThreadWarden.setup(rootNodeThread1);
rootNodeThread1.attachChild(childThread1);
return null;
});

Future<Void> mutationFuture2 = executorService2.submit(() -> {
SceneGraphThreadWarden.setup(rootNodeThread2);
rootNodeThread2.attachChild(childThread2);
return null;
});

// These should complete without exceptions, these are independent scene graphs
mutationFuture1.get();
mutationFuture2.get();

// try to use a child from one thread on the other, this should exception as the child is reserved
expectSceneGraphException(executorService.submit(() -> {
SceneGraphThreadWarden.setup(rootNodeThread1);
rootNodeThread1.attachChild(childThread2);
return null;
}));
}

/**
* Where there are multiple JME roots this tests that is *is* allowed to remove a child from one root and attach it
* to another as long as the detachments and reattachments are done in the right threads.
*
* <p>
* This is a weird thing to do and unlikely to come up in practice but is allowed
* </p>
*/
@Test
public void testCanTransferNodeBetweenMultipleSimultaneousRootThreads() throws ExecutionException, InterruptedException {
Node rootNodeThread1 = new Node("root1");
Node rootNodeThread2 = new Node("root2");

// Create a child node and attach it to the root node
Node childThread1 = new Node("child1");
Node childThread2 = new Node("child2");
Node childTransferred = new Node("childTransferred");

// on two threads set up two root nodes (for two JME applications on the same VM)
Future<Void> mutationFuture1 = executorService.submit(() -> {
SceneGraphThreadWarden.setup(rootNodeThread1);
rootNodeThread1.attachChild(childThread1);
rootNodeThread1.attachChild(childTransferred);
return null;
});

Future<Void> mutationFuture2 = executorService2.submit(() -> {
SceneGraphThreadWarden.setup(rootNodeThread2);
rootNodeThread2.attachChild(childThread2);
return null;
});

// These should complete without exceptions, these are independent scene graphs
mutationFuture1.get();
mutationFuture2.get();

Future<Void> legalRemovalFuture = executorService.submit(() -> {
childTransferred.removeFromParent();
return null;
});

legalRemovalFuture.get();

Future<Void> legalAttachmentFuture = executorService2.submit(() -> {
rootNodeThread2.attachChild(childTransferred);
return null;
});
legalAttachmentFuture.get();
}

/**
* Creates a single-threaded executor service with daemon threads.
Expand All @@ -313,4 +409,17 @@ private static ThreadFactory daemonThreadFactory() {
return t;
};
}

private void expectSceneGraphException(Future<Void> future){
try {
future.get();
fail("Expected an IllegalThreadSceneGraphMutation exception");
} catch (ExecutionException e) {
// This is expected - verify it's the right exception type
assertTrue("Expected IllegalThreadSceneGraphMutation, got: " + e.getCause().getClass().getName(),
e.getCause() instanceof IllegalThreadSceneGraphMutation);
} catch (InterruptedException e){
fail("Unexpected InterruptedException");
}
}
}