IndexOutOfBoundsException in Parent::updateCachedBounds when visibility changes
John Hendrikx
john.hendrikx at gmail.com
Wed Jun 25 12:25:38 UTC 2025
Hi,
The possibility for code being called re-entrantly on the same thread is
unfortunately something that slips the minds of developers quite easily
when they develop single threaded code. Developers assume that their
private fields will be in a consistent state because code being executed
will always complete before the same code is called again. However, in
JavaFX, there are many opportunities for 3rd party code to run via
property listeners or event handlers -- such 3rd party code can trigger
basically anything, and can sometimes lead to code in Parent to be
called again re-entrantly at a deeper stack nesting. If such a handler
is triggered at a point where the Parent class is only in a partially
consistent state, its own code will get confused.
This means that a class like Parent that has several "optimizations"
that make heavy use of private fields needs to be extremely careful WHEN
to make property modifications or trigger events that may have external
listeners attached to them. Basically, any observable property should
NEVER be modified when the class is not in a fully consistent state.
Simply adding a child to the children list, or changing the parent
property, must only be done while the class is in a consistent state as
who knows what may be listening to such properties...
This knowledge may help you to trace the problem; look for any property
listeners or event handlers that may be directly or indirectly triggered
by something that Parent is doing (ie. a listener on its children list,
on the parent property, etc).
The solution IMHO is not in trying to catch exceptions and try to
recover; this is extremely hard, and I think the only possible way to
recover is to fully reset the state and peer information as if created
anew. It would likely be easier to either de-optimize the Parent class
and have it make far less assumptions (and then see if there really was
a need for these optimizations in the first place, and redo some if
needed, but with these problems in mind) -- or identify any points where
an observable property is being modified and ensuring this is only done
at safe locations when the class is in a consistent state.
--John
On 25/06/2025 11:06, Dean Wookey wrote:
>
> Hi Everyone,
>
> We've also been experiencing this problem over the years. It seems
> to be related to JDK-8198577.
>
> Once it goes wrong, each pulse hits the issue repeated meaning it
> can never escape. It's rare, but extremely disruptive when it does
> occur because the user loses what they've been working on and has
> to restart the app.
>
> I've tried really hard to figure out the conditions this happens
> in. I don't think it's a multiple thread issue (although for some
> people it almost certainly could be triggered that way) because
> we've put conditional breakpoints that trigger whenever anything
> that could affect dirty children is done off the app thread. We've
> got assert Platform.isFXApplicationThread() all over our app to
> make sure the threading is happening properly.
>
> What I think is happening is that getChildTransformedBounds which
> is being called inside the updateCachedBounds loop, can in some
> rare cases, end up triggering a call to updateCachedBounds on the
> same node. Basically updateCachedBounds can call itself
> recursively. This is a snipped from Parent.java in updateCachedBounds.
>
> // this checks the newly added nodes first, so if dirtyNodes is the
> // whole children list, we can end early
> for (int i = dirtyNodes.size() - 1; remainingDirtyNodes > 0; --i) {
> final Node node = dirtyNodes.get(i);
> if (node.boundsChanged) {
> // assert node.isVisible();
> node.boundsChanged = false;
> --remainingDirtyNodes;
> tmp = getChildTransformedBounds(node,
> BaseTransform.IDENTITY_TRANSFORM, tmp);
>
> In the code above, if this gets called recursively through
> getChildTransformedBounds, then node.boundsChanged will change to
> false for all the nodes which stops remainingDirtyNodes from being
> updated and i eventually goes negative.
>
> We tried to fix the scene graph when this happens by catching the
> exception in the Thread.setDefaultUncaughtExceptionHandler but it
> didn't work. Maybe Christopher's suggested fix would work, but as
> Kevin says "It needs to be tested to ensure that when we get the
> AIOOBE that we can recover. It wouldn't solve anything if we catch
> and log that exception only to have it fail shortly after because
> the scene graph isn't in a good state (I don't know whether that
> would be the case, but it's something that needs to be checked)."
>
> Here's how we tried to fix the scene graph when we caught the
> error. The "Fixing IOB Issue" log gets hit all the time, but it
> doesn't find any problems, and in the next pulse it hits the
> problem again with various different stack traces until it settles
> on one. In our latest example of the error, it first occurred
> during a Platform.runLater and not during the pulse, but then all
> subsequent issues happen during the pulse.
>
> protected static void checkSpecialException(Throwable t) {
> if (t instanceof IndexOutOfBoundsException) {
> fixIndexOutOfBounds(t);
> }
> }
>
> public static void fixIndexOutOfBounds(Throwable throwable) {
> FXUtilities.log(EmbraceDesktop.class,
> org.slf4j.event.Level.INFO <http://org.slf4j.event.Level.INFO>,
> "Fixing IOB Issue");
> try {
> Field dirtyChildrenCountField =
> Parent.class.getDeclaredField("dirtyChildrenCount");
> dirtyChildrenCountField.setAccessible(true);
> Field dirtyChildrenField =
> Parent.class.getDeclaredField("dirtyChildren");
> dirtyChildrenField.setAccessible(true);
> Set<Scene> apps = applicationManager.getApplications();
> ArrayList<Node> brokenStack = new ArrayList<>();
> for (Scene s: apps) {
> fixTreeRecursive(dirtyChildrenCountField,
> dirtyChildrenField, s.getRoot(), brokenStack);
> }
> if (brokenStack.size() > 0) {
> StringBuilder errorStack = new StringBuilder();
> for (Node n: brokenStack) {
> errorStack.append(n.getClass().getSimpleName()
> + " " + String.join( ",", n.getStyleClass())).append("\n");
> }
> EmbraceAnalytics.logCrash("Index out of bounds
> crash",errorStack.toString(), throwable);
> }
>
>
> }
> catch (Throwable t2) {
> FXUtilities.log(EmbraceDesktop.class,
> org.slf4j.event.Level.ERROR, "Exception while fixing tree", t2);
> }
> }
>
> protected static boolean fixTreeRecursive(Field
> dirtyChildrenCountField, Field dirtyChildrenField, Parent parent,
> ArrayList<Node> brokenStack) throws IllegalAccessException {
> List<?> dirtyChildren = (List<?>)
> dirtyChildrenField.get(parent);
> int dirtyChildrenCount = (int)
> dirtyChildrenCountField.get(parent);
> if (dirtyChildren != null) {
> if (dirtyChildrenCount > dirtyChildren.size()) {
> FXUtilities.log(EmbraceDesktop.class,
> org.slf4j.event.Level.ERROR, "Offending node1 was " +
> parent.getClass().getSimpleName());
> dirtyChildrenCountField.set(parent,
> dirtyChildren.size());
> brokenStack.add(parent);
> return true;
> }
> }
> else {
> if (parent.getChildrenUnmodifiable().size() <
> dirtyChildrenCount) {
> FXUtilities.log(EmbraceDesktop.class,
> org.slf4j.event.Level.ERROR, "Offending node2 was " +
> parent.getClass().getSimpleName());
> dirtyChildrenCountField.set(parent,
> parent.getChildrenUnmodifiable().size());
> brokenStack.add(parent);
> return true;
> }
> }
> for (Node n: parent.getChildrenUnmodifiable()) {
> if (n instanceof Parent) {
> boolean error =
> fixTreeRecursive(dirtyChildrenCountField, dirtyChildrenField,
> (Parent)n, brokenStack);
> if (error) {
> brokenStack.add(parent);
> FXUtilities.log(EmbraceDesktop.class,
> org.slf4j.event.Level.ERROR, "Parent was " +
> parent.getClass().getSimpleName());
> }
> return error;
> }
> }
> return false;
> }
>
> I think we should we should put the index check potential fix in
> and log when it happens. As far as we can tell, if this issue gets
> hit, it's catastrophic 100% of the time. The fix might resolve the
> issue. It can't really make it any worse. Another thing we should
> do is add a check for recursive entry to that method and log when
> that occurs. That's (I think) the real issue, and without a stack
> trace of that, it's hard to find the root cause.
>
> I don't know if anyone else has experienced this issue and has
> insights/workarounds?
>
> Dean
>
>
> On Mon, Mar 24, 2025 at 5:22 PM Christopher Schnick
> <crschnick at xpipe.io> wrote:
>
> Hello,
>
> We encountered an issue after updating our application
> implementation to frequently change the visibility of nodes. We
> are essentially now running an implementation that very frequently
> changes the visibility of various children nodes based on when
> they are needed and shown. When the user performs a lot of
> actions, the visibility of many nodes will be changed rapidly.
>
> For that, there are many listeners in place that listen for bounds
> changes of nodes to recheck whether they need to be made visible
> or not. All the visibility changes are queued up, so they are not
> immediately done in the listener after any bounds changes of
> parents. They are all properly done on the platform thread with
> runLater. When this implementation is running on many client
> systems, we sometimes receive an error report with an exception
> that looks something like this:
>
> java.lang.IndexOutOfBoundsException: Index -1 out of bounds for
> length 2
> at
> java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:100)
> at
> java.base/jdk.internal.util.Preconditions.outOfBoundsCheckIndex(Preconditions.java:106)
> at
> java.base/jdk.internal.util.Preconditions.checkIndex(Preconditions.java:302)
> at java.base/java.util.Objects.checkIndex(Objects.java:365)
> at java.base/java.util.ArrayList.get(ArrayList.java:428)
> at
> javafx.base at 25-ea/com.sun.javafx.collections.ObservableListWrapper.get(ObservableListWrapper.java:88)
> at
> javafx.base at 25-ea/com.sun.javafx.collections.VetoableListDecorator.get(VetoableListDecorator.java:326)
> at
> javafx.graphics at 25-ea/javafx.scene.Parent.updateCachedBounds(Parent.java:1769)
> at
> javafx.graphics at 25-ea/javafx.scene.Parent.recomputeBounds(Parent.java:1713)
> at
> javafx.graphics at 25-ea/javafx.scene.Parent.doComputeGeomBounds(Parent.java:1566)
> at
> javafx.graphics at 25-ea/javafx.scene.Parent$1.doComputeGeomBounds(Parent.java:116)
> at
> javafx.graphics at 25-ea/com.sun.javafx.scene.ParentHelper.computeGeomBoundsImpl(ParentHelper.java:84)
> at
> javafx.graphics at 25-ea/com.sun.javafx.scene.layout.RegionHelper.superComputeGeomBoundsImpl(RegionHelper.java:78)
> at
> javafx.graphics at 25-ea/com.sun.javafx.scene.layout.RegionHelper.superComputeGeomBounds(RegionHelper.java:62)
> at
> javafx.graphics at 25-ea/javafx.scene.layout.Region.doComputeGeomBounds(Region.java:3301)
> at
> javafx.graphics at 25-ea/javafx.scene.layout.Region$1.doComputeGeomBounds(Region.java:166)
> at
> javafx.graphics at 25-ea/com.sun.javafx.scene.layout.RegionHelper.computeGeomBoundsImpl(RegionHelper.java:89)
> at
> javafx.graphics at 25-ea/com.sun.javafx.scene.NodeHelper.computeGeomBounds(NodeHelper.java:101)
> at
> javafx.graphics at 25-ea/javafx.scene.Node.updateGeomBounds(Node.java:3908)
> at
> javafx.graphics at 25-ea/javafx.scene.Node.getGeomBounds(Node.java:3870)
> at
> javafx.graphics at 25-ea/javafx.scene.Node.getLocalBounds(Node.java:3818)
> at
> javafx.graphics at 25-ea/javafx.scene.Node.updateTxBounds(Node.java:3972)
> at
> javafx.graphics at 25-ea/javafx.scene.Node.getTransformedBounds(Node.java:3764)
> at
> javafx.graphics at 25-ea/javafx.scene.Node.updateBounds(Node.java:828)
> at
> javafx.graphics at 25-ea/javafx.scene.Parent.updateBounds(Parent.java:1900)
> at
> javafx.graphics at 25-ea/javafx.scene.Scene$ScenePulseListener.pulse(Scene.java:2670)
> at
> javafx.graphics at 25-ea/com.sun.javafx.tk.Toolkit.runPulse(Toolkit.java:380)
> at
> javafx.graphics at 25-ea/com.sun.javafx.tk.Toolkit.firePulse(Toolkit.java:401)
> at
> javafx.graphics at 25-ea/com.sun.javafx.tk.quantum.QuantumToolkit.pulse(QuantumToolkit.java:592)
> at
> javafx.graphics at 25-ea/com.sun.javafx.tk.quantum.QuantumToolkit.pulse(QuantumToolkit.java:572)
> at
> javafx.graphics at 25-ea/com.sun.javafx.tk.quantum.QuantumToolkit.pulseFromQueue(QuantumToolkit.java:565)
> at
> javafx.graphics at 25-ea/com.sun.javafx.tk.quantum.QuantumToolkit.lambda$runToolkit$6(QuantumToolkit.java:346)
> at
> javafx.graphics at 25-ea/com.sun.glass.ui.InvokeLaterDispatcher$Future.run$$$capture(InvokeLaterDispatcher.java:95)
> at
> javafx.graphics at 25-ea/com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java)
>
> The index out of bounds is not always the same, there are various
> variations of this. It happens on all operating systems. It seems
> like there is a very specific scenario where an index can be out
> of bounds. This happens very rarely, like only a few times out of
> some hundred application runs, so I tried my best at forcing it to
> reproduce.
>
> The following reproducer works most of the time, but it might have
> to be run multiple times. I am aware that it eventually results in
> a StackOverflow, but that was the best way to force it reliably,
> by just continuously spamming visibility changes to eventually
> encounter this rare issue. But I want to emphasize that the same
> error also occurs naturally when not being forced like this, but
> it is just a lot more rare. So the StackOverflow in the reproducer
> has nothing to do with this issue, it also happens later on.
>
> import javafx.application.Application;
> import javafx.scene.Scene;
> import javafx.scene.control.Button;
> import javafx.scene.layout.Region;
> import javafx.scene.layout.StackPane;
> import javafx.scene.layout.VBox;
> import javafx.stage.Stage;
>
> import java.io.IOException;
>
> public class ParentBoundsBug extends Application {
>
> @Override public void start(Stage stage) throws IOException {
> Scene scene = new Scene(createContent(), 640, 480);
> stage.setScene(scene);
> stage.show();
> stage.centerOnScreen();
> }
>
> private Region createContent() {
> var b1 = new Button("Click me!");
> var b2 = new Button("Click me!");
> var vbox = new VBox(b1, b2);
> b1.boundsInParentProperty().addListener((observable, oldValue, newValue) -> {
> vbox.setVisible(!vbox.isVisible());
> });
> b2.boundsInParentProperty().addListener((observable, oldValue, newValue) -> {
> vbox.setVisible(!vbox.isVisible());
> });
> vbox.boundsInParentProperty().addListener((observable, oldValue, newValue) -> {
> vbox.setVisible(!vbox.isVisible());
> });
>
> var stack = new StackPane(vbox, new StackPane());
> stack.boundsInParentProperty().addListener((observable, oldValue, newValue) -> {
> vbox.setVisible(!vbox.isVisible());
> });
> return stack;
> }
>
> public static void main(String[] args) {
> launch();
> }
> }
>
>
> It doesn't necessarily have something to do with running the
> visibility change directly in the listener, our application does a
> runLater to change the visibility state, still with the same
> results. To properly debug this, you will have to launch the
> reproducer with a bigger stack size like -Xss8m to increase the
> chance that it occurs. Then, you can just set a breakpoint at
> jdk.internal.util.Preconditions:302, and wait for it to trigger
> the OOB eventually.
>
> This problem is currently the biggest JavaFX issue for us as it
> breaks the layout and usually requires a restart to fix.
>
> Looking at the bounds calculation code, the list index bounds
> check is very optimistic in that it doesn't check any indices and
> relies on multiple assumtions to hold. So if it is very difficult
> to find the cause, a simple index bounds check for the list access
> would also work fine.
>
> Best
> Christopher Schnick
>
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://mail.openjdk.org/pipermail/openjfx-dev/attachments/20250625/9983adad/attachment-0001.htm>
More information about the openjfx-dev
mailing list