Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(draf): ChangeListener events are incorrect or misleading when a nested change occurs #837

Closed
wants to merge 13 commits into from

Conversation

hjohn
Copy link
Collaborator

@hjohn hjohn commented Jul 17, 2022

This contains the following:

  • Nested changes or invalidations using ExpressionHelper are delayed until the current emission completes
    • This fixes odd change events being produced (with incorrect oldValue)
    • Also fixes a bug in ExpressionHelper where a nested change would unlock the listener list early, which could cause a ConcurrentModificationException if a nested change was combined with a remove/add listener call
  • A test for ExpressionHelper to verify the new behavior
  • A test for all *Property and *Binding classes that verifies correct listener behavior at the API level (this tests gets 85% coverage on ExpressionHelper on its own, the only thing it is not testing is the locking behavior, which is not relevant at the API level).
  • A fix for WebColorFieldSkin which triggered a nested change which used a flag to prevent an event loop (I've changed it now to match how DoubleFieldSkin and IntegerFieldSkin do it

Progress

  • Change must not contain extraneous whitespace
  • Commit message must refer to an issue
  • Change must be properly reviewed (2 reviews required, with at least 1 Reviewer, 1 Author)

Integration blockers

 ⚠️ The commit message does not reference any issue. To add an issue reference to this PR, edit the title to be of the format issue number: message. (failed with the updated jcheck configuration)
 ⚠️ Too few reviewers with at least role reviewer found (have 0, need at least 1) (failed with the updated jcheck configuration)

Reviewers

Reviewing

Using git

Checkout this PR locally:
$ git fetch https://git.openjdk.org/jfx.git pull/837/head:pull/837
$ git checkout pull/837

Update a local copy of the PR:
$ git checkout pull/837
$ git pull https://git.openjdk.org/jfx.git pull/837/head

Using Skara CLI tools

Checkout this PR locally:
$ git pr checkout 837

View PR using the GUI difftool:
$ git pr show -t 837

Using diff file

Download this PR as a diff file:
https://git.openjdk.org/jfx/pull/837.diff

Webrev

Link to Webrev Comment

@bridgekeeper
Copy link

bridgekeeper bot commented Jul 17, 2022

👋 Welcome back jhendrikx! A progress list of the required criteria for merging this PR into master will be added to the body of your pull request. There are additional pull request commands available for use with this pull request.

@openjdk openjdk bot added the rfr Ready for review label Jul 17, 2022
@mlbridge
Copy link

mlbridge bot commented Jul 17, 2022

Webrevs

@kevinrushforth
Copy link
Member

/reviewers 2

@openjdk
Copy link

openjdk bot commented Jul 18, 2022

@kevinrushforth
The total number of required reviews for this PR (including the jcheck configuration and the last /reviewers command) is now set to 2 (with at least 1 Reviewer, 1 Author).

@kevinrushforth kevinrushforth self-requested a review July 18, 2022 12:52
Copy link
Collaborator

@mstr2 mstr2 left a comment

Choose a reason for hiding this comment

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

I've re-read the discussion on the mailing list. The approach as implemented in this PR is a good solution, as it has a very low implementation and runtime overhead, and gets us to having consistent histories for change listeners.

@openjdk
Copy link

openjdk bot commented Jan 2, 2023

@hjohn this pull request can not be integrated into master due to one or more merge conflicts. To resolve these merge conflicts and update this pull request you can run the following commands in the local repository for your personal fork:

git checkout feature/delayed-nested-emission
git fetch https://git.openjdk.org/jfx master
git merge FETCH_HEAD
# resolve conflicts and follow the instructions given by git merge
git commit -m "Merge master"
git push

@openjdk openjdk bot added the merge-conflict Pull request has merge conflict with target branch label Jan 2, 2023
@openjdk openjdk bot removed the merge-conflict Pull request has merge conflict with target branch label Jan 3, 2023
@kevinrushforth kevinrushforth requested a review from arapte January 4, 2023 00:29
@kevinrushforth kevinrushforth removed their request for review January 31, 2023 18:40
@hjohn
Copy link
Collaborator Author

hjohn commented Feb 5, 2023

@arapte @nlisker what do you think?

@nlisker
Copy link
Collaborator

nlisker commented Feb 5, 2023

The fix for the bug with nested events releasing the lock seems fine. I'm still not sure if the behavioral change is what we want the result to be even if it's better than what we have now. I have mentioned this in #108 (comment). we need to decide on a spec first, and there are several facets to it as discussed in the mailing list link above (#837 (review)).

I will re-summarize what I think needs to be defined before implementing a behavior change like the one proposed here:

Listener order

When listeners are registered, they are added to some collection (an array, currently). The add/remove methods hint at an order, but don't guarantee one. Do we specify an order, or say it's unspecified?

Event dispatch order

The order in which listeners are triggered is not necessarily the order of the listeners above. Do we specify a dispatch order, or say it's unspecified? If it's specified, the listener order is probably also specified.

We are also dispatching invalidation events before change events arbitrarily. Do we dispatch the event to all listeners of one type and then to the other, or do we want to combine them according to a joint dispatch order? We either say that they are dispatched separately, together in some order (like registration), or that it's unspecified.

Nested listener modifications

If a listener is added/removed during an event dispatch, do we specify it will affect ("be in time for") the nested event chain, the outer event chain, or that it's unspecified?

Nested value modifications

If a listener changes the value of its property during an event dispatch, do we specify that the new event will trigger first, halting the current event dispatch (depth-first approach), or that the new event waits until current one finishes (breadth-first approach), or that it is unspecified? Do we guarantee that when a listener is invoked it sees the new value that was set by the latest nested change, or will it see the value that existed at trigger time, or is it unspecified?

If listeners affect each other with nested events, then the dispatch order matters.


If we answer "unspecified" to the questions above, it allows us more implementation freedom. It will also mean that listeners should be thought of as "lone wolves" - they are not intended to talk to each other or depend on each other; each should do something regardless of what the others are doing and assume its code "territory" is not affected by other listeners. The user takes responsibility for coupling them (nested modifications).
On the other hand, no guarantees bring limited usage. The question is, what is the intended usage?

@hjohn
Copy link
Collaborator Author

hjohn commented Feb 5, 2023

The fix for the bug with nested events releasing the lock seems fine. I'm still not sure if the behavioral change is what we want the result to be even if it's better than what we have now. I have mentioned this in #108 (comment). we need to decide on a spec first, and there are several facets to it as discussed in the mailing list link above (#837 (review)).

Is there any doubt as to what the old value should be? I think this is a bug fix only, and although I think it is good to discuss the whole listener system, I think we shouldn't delay this fix too much.

After all, it is a very common pattern to use the old value for unregistering listeners when listening to a property chain (like node -> scene), and if the "old property" is ever incorrect, then the removeListener call will silently fail (shame we can't change that any more).

I will re-summarize what I think needs to be defined before implementing a behavior change like the one proposed here:

I don't think this is a behavior change, it is a bug fix, unless we document that ChangeListener old value is a "best effort value" that cannot be trusted to contain anything useful when nested changes occur. Nested changes can easily occur in the huge web of properties that are sometimes woven (take the layout code for example).

Listener order

When listeners are registered, they are added to some collection (an array, currently). The add/remove methods hint at an order, but don't guarantee one. Do we specify an order, or say it's unspecified?

I think it is too late to change anything significant here. Also the primary reason we've been pushing for a change here is I think the poor (remove) performance when there are many listeners. However, I think having many listeners on a single property is always an unwanted situation. The remove performance is simply the first problem you'll encounter. The next problem you'll encounter is when 10.000+ listeners need to be notified of a change... it will quickly become unworkable.

Furthermore, there are data structures that have O(1) add(end) + remove(T) performance and O(n) iterate performance, while still being sequential and allowing duplicates. The remove "problem" could be solved that way without breaking existing code, but that will not speed up notification (which will be your next problem). I've mostly abandoned fixes in this area as I think it is never a good idea to have so many listeners that remove performance becomes an issue.

So, if it were up to me, and I'm pretty convinced this is going to break quite some code out there, I'd say it should be:

  • listeners are called in the order they're registered
  • duplicates are allowed
  • removing a listener removes the first instance of that listener (not the last or a random one)

Since there are data structures that can handle the requirements we need (we only need add-at-end, remove(T) and iteration) I think we're not locking ourselves into too much problems (the cost of such a data structure is slower get(index) and remove(index) performance, but we don't need these).

Event dispatch order

The order in which listeners are triggered is not necessarily the order of the listeners above. Do we specify a dispatch order, or say it's unspecified? If it's specified, the listener order is probably also specified.

I'll consider this the same problem, with the same risks, see above.

We are also dispatching invalidation events before change events arbitrarily. Do we dispatch the event to all listeners of one type and then to the other, or do we want to combine them according to a joint dispatch order? We either say that they are dispatched separately, together in some order (like registration), or that it's unspecified.

I think there may be good reasons to do invalidation first, but we don't need to specify it. An experiment where these orders are changed and running all the tests probably can give some insights here. Either leave unspecified or specify as it works currently as. I think here it is less likely things will break though.

Nested listener modifications

If a listener is added/removed during an event dispatch, do we specify it will affect ("be in time for") the nested event chain, the outer event chain, or that it's unspecified?

Nested value modifications

If a listener changes the value of its property during an event dispatch, do we specify that the new event will trigger first, halting the current event dispatch (depth-first approach), or that the new event waits until current one finishes (breadth-first approach), or that it is unspecified? Do we guarantee that when a listener is invoked it sees the new value that was set by the latest nested change, or will it see the value that existed at trigger time, or is it unspecified?

I don't think we should care about depth-first, breadth-first. The only thing that I think is important here is that the contract of ChangeListener is respected. I think that that contract should be:

  • oldValue and newValue should always be different values
  • a subsequent notification should have newValue as oldValue as that's almost a requirement to do anything sensible with oldValue, and also implied (it is the old value, aka. the previous value, the value that this property held since its last change)

So when I change something form A to B, and a nested listener changes it from B to C, then any of these change events are fine:

  • A -> B + B -> C
  • A -> C

But not:

  • A -> B + A -> C [listener not unregistered on B]
  • A -> B + C -> C [listener not unregistered on B]
  • A -> C + B -> C [listener registered twice on C]

If listeners affect each other with nested events, then the dispatch order matters.

If we answer "unspecified" to the questions above, it allows us more implementation freedom. It will also mean that listeners should be thought of as "lone wolves" - they are not intended to talk to each other or depend on each other; each should do something regardless of what the others are doing and assume its code "territory" is not affected by other listeners. The user takes responsibility for coupling them (nested modifications). On the other hand, no guarantees bring limited usage. The question is, what is the intended usage?

This PR does not make listeners more/less dependent than they are. It fixes one major bug that can cause ConcurrentModificationException and it ensures a useful old value in the face of nested changes.

Since we probably can't disallow duplicate listeners at this stage, any new implementation needs to support this. This makes simple map based solutions a lot trickier (they'd need a counter). We'd also be going against years of standard practices for UI frameworks that work with listeners and which are using the add/remove pattern (Swing, AWT). They all are ordered and allow duplicates.

I think that at this stage, it would be best to document it as it works currently, leaving maybe a few small areas unspecified still. Any performance problems can be tackled within these restrictions. It is certainly possible to make a structure that has all of these qualities:

  • sequential
  • allows duplicates
  • fast add-at-end
  • fast remove(T), removing the first match
  • fast iteration
  • low memory use (no wrapper needed per listener), although you'll never beat ArrayList
  • no need for copying

Again though, not sure what problem that will solve exactly... 10.000 listeners on a single property is going to perform badly no matter what you do.

Copy link
Member

@arapte arapte left a comment

Choose a reason for hiding this comment

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

I did test this well and looks good to me. I tested changing the property value in more than one listener and combination of Invalidation and Change Listener. The behavior looks good and did not seem like it will cause regression(of course depends on specific scenarios)
I think the change may need a doc update, to explain this behavior.

Coming to the broad level of discussion, I think this change can still go in and other aspects can be worked on later whenever we get to them.

@hjohn
Copy link
Collaborator Author

hjohn commented Feb 14, 2023

@arapte and others

I did test this well and looks good to me. I tested changing the property value in more than one listener and combination of Invalidation and Change Listener. The behavior looks good and did not seem like it will cause regression(of course depends on specific scenarios) I think the change may need a doc update, to explain this behavior.

Did you have a specific place in mind for a documentation update? I just re-read the documentation for ObservableValue#addListener(ChangeListener) and ChangeListener and the only thing I found that may need updating is this comment on ChangeListener#changed:

In general, it is considered bad practice to modify the observed value in this method.

Note that JavaFX itself does this sometimes to restore values if they don't match requirements.

It's a bit of a tough requirement. If you do any work in a ChangeListener that touches other properties, those may trigger more changes, and those may touch the original property again (the original change call would still be higher up on the call stack) without realizing it would be a nested change.

Perhaps it could be reworded to:

Changing the observed value in this method will result in all listeners being notified of this latest change after the initial change notification (with the original old and values) has completed. The listeners that still needed to be notified may see a new value that differs from a call to {@link ObservableValue#get}. All listeners are then notified again with an old value equal to the initial new value, and a new value with the latest value.

I added it in already. If there are any other places where you think documentation should be update, please let me know.

@nlisker
Copy link
Collaborator

nlisker commented Feb 14, 2023

I don't think we should care about depth-first, breadth-first. The only thing that I think is important here is that the contract of ChangeListener is respected. I think that that contract should be:
...

I'll be more concrete. Here is my test program:

public class ListenersTest {

    private static int inv = 0;

    public static void main(String[] args) {
        with1Change();
    }

    static void with1Change() {
        inv = 0;
        var property = new SimpleIntegerProperty(0);

        ChangeListener<? super Number> listenerA = (obs, ov, nv) -> {
            inv++;
            String spaces = IntStream.range(1, inv).mapToObj(i -> "  ").reduce(String::concat).orElse("") + inv;
            System.out.println(spaces + " bA " + ov + "->" + nv + " (" + property.get() + ")");
            property.set(5);
            System.out.println(spaces + " aA " + ov + "->" + nv + " (" + property.get() + ")");
        };
        property.addListener(listenerA);

        ChangeListener<? super Number> listenerB = (obs, ov, nv) -> {
            inv++;
            String spaces = IntStream.range(1, inv).mapToObj(i -> "  ").reduce(String::concat).orElse("") + inv;
            System.out.println(spaces + " bB "  + ov + "->" + nv + " (" + property.get() + ")");
            System.out.println(spaces + " aB " + ov + "->" + nv + " (" + property.get() + ")");
        };
        property.addListener(listenerB);

        property.set(1);
        System.out.println("---------\n");
    }
}

With the patch:

1 bA 0->1 (1)
1 aA 0->1 (5)
  2 bB 0->1 (5)
  2 aB 0->1 (5)
    3 bA 1->5 (5)
    3 aA 1->5 (5)
      4 bB 1->5 (5)
      4 aB 1->5 (5)

With your patch, each event finishes its run and only then the next event happens. This is the "breadth-first" approach.
However, there is another one:

1 bA 0->1 (1)
  2 bA 1->5 (5)
  2 aA 1->5 (5)
    3 bB 1->5 (5)
    3 aB 1->5 (5)
1 aA 0->1 (5)
      4 bB 0->1 (5)
      4 aB 0->1 (5)

This approach starts events before the previous ones finished, and goes back to the original event later. This is the "depth-first" approach. I don't think that either is wrong. This one makes sense and it's a behavior I can reason about: the listener is loyal to the event at the time it happened (and the "real" value is accessible with get).

Without the patch:

1 bA 0->1 (1)
  2 bA 1->5 (5)
  2 aA 1->5 (5)
    3 bB 1->5 (5)
    3 aB 1->5 (5)
1 aA 0->1 (5)
      4 bB 0->5 (5)
      4 aB 0->5 (5)

I agree that at step 4 the 0->5 event is wrong because the events are only 0->1 and 1->5.

If you comment out the line property.addListener(listenerB); (only register A), then both with and without the patch I get

1 bA 0->1 (1)
  2 bA 1->5 (5)
  2 aA 1->5 (5)
1 aA 0->1 (5)

while with delaying nested events I would expect:

1 bA 0->1 (1)
1 aA 0->1 (5)
  2 bA 1->5 (5)
  2 aA 1->5 (5)

So this looks inconsistent to me.

The fix for the lock being released is good regardless, it's the behavioral change that I'm not sold on.

Comment on lines -51 to +57
* In general, it is considered bad practice to modify the observed value in
* this method.
* Changing the observed value in this method will result in all listeners
* being notified of this latest change after the initial change
* notification (with the original old and values) has completed.
* The listeners that still needed to be notified may see a new value that
* differs from a call to {@link ObservableValue#getValue}. All listeners are
* then notified again with an old value equal to the initial new value,
* and a new value with the latest value.
Copy link
Member

Choose a reason for hiding this comment

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

This seems like a right place.
It is still a bad practice to change the value in this method, so I would recommend to keep the warning.
I did little rewording, please check how does this look:

* In general, it is considered a bad practice to modify the observed value in
* this method. But if still modified, then the notifications for this change
* are deferred until all the listeners are notified of current change.
* This results in a minor inconsistency of new value with the listeners
* that are still pending to be notified of current change that the listeners
* would receive a new value that differs from a call to {@link ObservableValue#getValue}.

Old text was uppercased while new text is always lowercase.
@nlisker
Copy link
Collaborator

nlisker commented Feb 18, 2023

I couldn't quite see which you prefer here; you said "This one makes sense" but not quite sure which version it refers to (I suppose the depth first version?)

I should have said "this one makes sense too". The point was that while your fix is good, it's not the only good fix, and I wasn't sold on choosing the one in this PR just yet.
If "breadth-first" is easier to do and requires less memory to remember the old values, then I have no problem with "depth-first". I don't have a strict preference for either approach mostly because I don't do nested modifications myself.

I'm not at all sure that we need so specify this behavior, but we need to be consistent with it. @arapte Why do you think we should say what order the nested event is dispatched at?

I'm also investigating the effect of this patch in conjunction with addition/removal of listeners in conjunction with a nested event.

@hjohn
Copy link
Collaborator Author

hjohn commented Feb 18, 2023

I couldn't quite see which you prefer here; you said "This one makes sense" but not quite sure which version it refers to (I suppose the depth first version?)

I should have said "this one makes sense too". The point was that while your fix is good, it's not the only good fix, and I wasn't sold on choosing the one in this PR just yet. If "breadth-first" is easier to do and requires less memory to remember the old values, then I have no problem with "depth-first".

Confusing me again here :-) Did you mean to say "breadth-first" where you said "depth-first" ?

Breadth first is for sure a lot easier, as the old values are much easier to get correct for it.

I've given depth first some more thought, but every approach I think of requires some kind of stack or tracking of which listeners weren't notified yet of the original set that we wanted to notify (and I think we'll need to remember even more if there is another change before the first and second one finishes).

I don't have a strict preference for either approach mostly because I don't do nested modifications myself.

I've been wondering how often it happens -- I may be able to put a breakpoint or println in the nested code and see if this happens in a large FX application (in layout code perhaps).

I'm not at all sure that we need so specify this behavior, but we need to be consistent with it. @arapte Why do you think we should say what order the nested event is dispatched at?

Perhaps we could limit the explanation to just mentioning that its possible the "new value" may not be the same as ObservableValue#getValue if nested changes occured. Also, I'm curious why it would still be a bad practice to modify the value again (now that the old values are always correct); I think it's pretty much the standard pattern to veto a change (like a numeric field rejecting out of range values or non-numeric characters).

I'm also investigating the effect of this patch in conjunction with addition/removal of listeners in conjunction with a nested event.

Yes, perhaps this may require an additional unit test.

@nlisker if we're going for breadth-first, shall I fix the cases for single listeners as well (finish the notification before calling the same listener again, instead of recursively calling into it?)

@nlisker
Copy link
Collaborator

nlisker commented Feb 18, 2023

Confusing me again here :-) Did you mean to say "breadth-first" where you said "depth-first" ?

Breadth first is for sure a lot easier, as the old values are much easier to get correct for it.

I've given depth first some more thought, but every approach I think of requires some kind of stack or tracking of which listeners weren't notified yet of the original set that we wanted to notify (and I think we'll need to remember even more if there is another change before the first and second one finishes).

Yes, sorry, I meant breadth-first. We can go with that one.

@nlisker if we're going for breadth-first, shall I fix the cases for single listeners as well (finish the notification before calling the same listener again, instead of recursively calling into it?)

I think that we need to be consistent, so barring any special reason for excepting single listeners from this change, I would say that we need to bring them in line with the generic listener.

Perhaps we could limit the explanation to just mentioning that its possible the "new value" may not be the same as ObservableValue#getValue if nested changes occured. Also, I'm curious why it would still be a bad practice to modify the value again (now that the old values are always correct); I think it's pretty much the standard pattern to veto a change (like a numeric field rejecting out of range values or non-numeric characters).

I'll think about what doc changes to make. We'll see what Ambarish thinks too.
Vetoing seems like a normal use case, but maybe there's another way of doing it. I never delved into this.

@nlisker
Copy link
Collaborator

nlisker commented Feb 18, 2023

I tried this test:

     static void withRemovalAnd2Changes() {
        inv = 0;
        var property = new SimpleIntegerProperty(0);

        ChangeListener<? super Number> listenerA = (obs, ov, nv) -> {
            inv++;
            String spaces = spaces();
            System.out.println(spaces + " bA " + ov + "->" + nv + " (" + property.get() + ")");
            property.set(5);
            System.out.println(spaces + " aA " + ov + "->" + nv + " (" + property.get() + ")");
        };

        ChangeListener<? super Number> listenerB = (obs, ov, nv) -> {
            inv++;
            String spaces = spaces();
            System.out.println(spaces + " bB "  + ov + "->" + nv + " (" + property.get() + ")");
            property.removeListener(listenerA);
            property.set(6);
            System.out.println(spaces + " aB " + ov + "->" + nv + " (" + property.get() + ")");
        };

        property.addListener(listenerA);
        property.addListener(listenerB);

        property.set(1);
    }

which removes a listener in a nested event. With this patch I got the following:

1 bA 0->1 (1)
1 aA 0->1 (5)
  2 bB 0->1 (5)
    3 bB 5->6 (6)
    3 aB 5->6 (6)
  2 aB 0->1 (6)
      4 bA 1->6 (6)
        5 bB 6->5 (5)
          6 bB 5->6 (6)
          6 aB 5->6 (6)
        5 aB 6->5 (6)
      4 aA 1->6 (6)
            7 bB 1->6 (6)
            7 aB 1->6 (6)

Because listener A is removed in B, we hit the inconsistency with 1 listener again. I need to work through this, but I'm not convinced this is what the output should be. One glaring question is, are nested listener additions/removals also deferred, or do they take effect immediately, in which case they shouldn't receive deferred nested events I think.

@nlisker
Copy link
Collaborator

nlisker commented Feb 18, 2023

With this test

     static void with2Changes() {
         inv = 0;
         var property = new SimpleIntegerProperty(0);

         ChangeListener<? super Number> listenerA = (obs, ov, nv) -> {
             inv++;
             String spaces = spaces();
             System.out.println(spaces + " bA " + ov + "->" + nv + " (" + property.get() + ")");
             property.set(5);
             System.out.println(spaces + " aA " + ov + "->" + nv + " (" + property.get() + ")");
         };

         ChangeListener<? super Number> listenerB = (obs, ov, nv) -> {
             inv++;
             String spaces = spaces();
             System.out.println(spaces + " bB "  + ov + "->" + nv + " (" + property.get() + ")");
             property.set(6);
             System.out.println(spaces + " aB " + ov + "->" + nv + " (" + property.get() + ")");
         };

         property.addListener(listenerA);
         property.addListener(listenerB);

         property.set(1);
     }

I get

1 bA 0->1 (1)
1 aA 0->1 (5)
  2 bB 0->1 (5)
  2 aB 0->1 (6)
    3 bA 1->6 (6)
    3 aA 1->6 (5)
      4 bB 1->6 (5)
      4 aB 1->6 (6)

I think that we are missing a 1->5 event originating in listener A, and maybe a 5->6 event. I'm honestly not sure what the behavior should be here.

@hjohn
Copy link
Collaborator Author

hjohn commented Feb 19, 2023

With this test

     static void with2Changes() {
         inv = 0;
         var property = new SimpleIntegerProperty(0);

         ChangeListener<? super Number> listenerA = (obs, ov, nv) -> {
             inv++;
             String spaces = spaces();
             System.out.println(spaces + " bA " + ov + "->" + nv + " (" + property.get() + ")");
             property.set(5);
             System.out.println(spaces + " aA " + ov + "->" + nv + " (" + property.get() + ")");
         };

         ChangeListener<? super Number> listenerB = (obs, ov, nv) -> {
             inv++;
             String spaces = spaces();
             System.out.println(spaces + " bB "  + ov + "->" + nv + " (" + property.get() + ")");
             property.set(6);
             System.out.println(spaces + " aB " + ov + "->" + nv + " (" + property.get() + ")");
         };

         property.addListener(listenerA);
         property.addListener(listenerB);

         property.set(1);
     }

I get

1 bA 0->1 (1)
1 aA 0->1 (5)
  2 bB 0->1 (5)
  2 aB 0->1 (6)
    3 bA 1->6 (6)
    3 aA 1->6 (5)
      4 bB 1->6 (5)
      4 aB 1->6 (6)

I think that we are missing a 1->5 event originating in listener A, and maybe a 5->6 event. I'm honestly not sure what the behavior should be here.

If you add the missing events, this would be an infinite loop, so that's probably not the best way to go.

I think you really need to look at the emissions as a whole, and then this doesn't look too bad. First, the indents and counts you use are a bit confusing. The first emission is:

Before: 0
Emission 1 {
  1 bA 0->1 (1)
  1 aA 0->1 (5)
  2 bB 0->1 (5)
  2 aB 0->1 (6)
}
Nested triggered: yes
After: 6

As changes occurred during the first emission, another is done afterwards. The changes are collapsed; the value is only re-evaluated after a full emission. I think that's probably the best thing we could do (or you'd get an infinite loop).

The second emission is:

  Before: 1
  {
    3 bA 1->6 (6)
    3 aA 1->6 (5)
    4 bB 1->6 (5)
    4 aB 1->6 (6)
  }
  Nested triggered: yes (it's always triggered with this listener setup, they can never agree)
  After: 6

The third emission is empty, as there is a !newValue.equals(oldValue) check. Invalidations may still fire.

I think it would be best to note that if there are multiple change listeners modifying the values, that it is up to the user to ensure they're not conflicting. Even though this case doesn't result in an infinite loop, it could easily have been (and under the old code it actually results in StackOverflowError).

@hjohn
Copy link
Collaborator Author

hjohn commented Feb 20, 2023

(about removing listener during event emission)

Because listener A is removed in B, we hit the inconsistency with 1 listener again. I need to work through this, but I'm not convinced this is what the output should be. One glaring question is, are nested listener additions/removals also deferred, or do they take effect immediately, in which case they shouldn't receive deferred nested events I think.

I've fixed the 1 listener case so it doesn't do the call recursively. I'll now take a look at listener addition/removal part. I think that a follow up emission triggered by a nested change should use the latest set of listeners and not continue using the initial set (that's how the old code behaved).

@kevinrushforth
Copy link
Member

I see that the same unit test has failed on all three platforms after your latest change.

@hjohn
Copy link
Collaborator Author

hjohn commented Feb 20, 2023

@kevinrushforth

Yes, that's correct (well not correct).

The cause is that this new notification variant collapses nested changes into a single change (by not recursively calling into the same listener). After I applied the same treatment to the single listener variant in ExpressionHelper one of the tests broke. Apparently, ScheduledService is doing some nested changes in an edge case that is tested as part of the callingCancelFromOnSucceededEventHandlerShouldStopScheduledService test and this latest change breaks it.

The reason it breaks is that it makes multiple changes while within a ChangeListener#changed call (it wants to do three state transitions in one go, from SUCCEEDED to READY to SCHEDULED to CANCELLED) but the new code collapses these to a single change.

So it seems that there is at least some JavaFX code that relies on the behavior that even nested changes should result in separate change notifications.

I'm now investigating possible solutions to this; as far as I can see, there are three options:

  1. Switch to using a depth-first algorithm for listener notification, which is more similar to the current behavior, but may be hard to implement with correct old values.
  2. Keep breadth first, but queue up each nested change and notify listeners for each of these.
  3. Adjusting how ScheduledService does these state transitions to go from SUCCEEDED to CANCELLED. I didn't see an easy fix for it after an hour or two of looking, and it may not be worth it as collapsing nested changes may be too great a change.

I'm currently looking into the first option to see if it can be done depth-first without having to do extensive tracking of which listeners were notified already and which didn't get any yet, to keep old values correct.

@hjohn
Copy link
Collaborator Author

hjohn commented Feb 21, 2023

I've included a fix which prevents nested changes from being collapsed into a single change. This should fix the failing test in ScheduledServiceTest.

There is still two open issues that I'm aware of, and which I'm looking into:

  1. ExpressionHelper can morph itself from Generic to single variants and vice versa. If such a change were to occur and another nested change is triggered, a different implementation of fireValueChangedEvent is called that may not have the same state as the other implementation (nested changes may potentially get lost).
  2. When listeners are added or removed, this currently doesn't take effect until all emissions have completed. This may or may not be a good thing. In the implementation before this PR, the changes would take effect for only the nested calls, but not for the emissions that are higher up on the stack.

@hjohn
Copy link
Collaborator Author

hjohn commented Feb 21, 2023

I've also come to the conclusion that a depth first approach is not really feasible unless you either allow for:

  • collapsing some changes into a single change (for the listeners called after listeners that triggered a nested change)
  • incorrect old values

Given three listeners, A, B, C where B changes values to even values, it's really hard to give the last listeners in the chain (C) correct values.

The implementation before this PR does:

  A: 0 -> 1
  B: 0 -> 1  [modifies value to 2, triggering nested emission]
         A: 1 -> 2
         B: 1 -> 2
         C: 1 -> 2
  C: 0 -> 2

A correct depth first approach would have to avoid sending out C: 1 -> 2, in which case it sees only a collapsed change. Sending C: 0 -> 1 first somehow is near impossible to keep track of, and then when it returns to the top level, it would need to send C: 1 -> 2.

The only option I still see that may work is to do this:

  A: 0 -> 1
  B: 0 -> 1  [modifies value to 2, triggering nested emission, breaks off initial emission completely]
         A: 1 -> 2
         B: 1 -> 2
         C: 0 -> 1  [inserted at the nested level, if we can somehow track the correct old value needed for this]
         C: 1 -> 2

For comparison, this PR does:

  A: 0 -> 1
  B: 0 -> 1  [tracks nested change to 2]
  C: 0 -> 1
  [first emission finished, next starts, there are no recursive calls]
  A: 1 -> 2
  B: 1 -> 2
  C: 1 -> 2

@bridgekeeper
Copy link

bridgekeeper bot commented Mar 31, 2023

@hjohn This pull request has been inactive for more than 4 weeks and will be automatically closed if another 4 weeks passes without any activity. To avoid this, simply add a new comment to the pull request. Feel free to ask for assistance if you need help with progressing this pull request towards integration!

@hjohn
Copy link
Collaborator Author

hjohn commented Apr 1, 2023

@nlisker @mstr2 I'm going to update this soon with an (IMHO) much better solution, that handles all the edge cases.

  • Recursive algorithm, listeners with a higher index get a summarized change if earlier listeners are changing values
  • No copying of listener lists ever occurs; instead, removing listeners is deferred (they're null-ed), and only removed when no notifications are running
  • This also means that a removed listener is immediately disabled as it is not part of some copy, so they will never receive another notification, nested or otherwise
  • An added listener is immediately added and will receive notifications immediately from the top level notification loop (nested loops never get that far as nested loops are stopped when they reach the same index as a higher level loop)
  • If after a top level notification completes it was discovered that one or more listeners were removed, the listener list is compacted (removing null elements) -- order is maintained (too much depends on it. This isn't a copy, but could be optimized if there were many removals (could use removeIf for this purpose which may be optimized for ArrayList).

The implementation has the following characteristics:

  1. For change listeners, the old value received is always the previous new value.
  2. The new value received always matches property.getValue()
  3. Listeners with a higher index receive less change events when earlier listeners make changes, but the events are all consistent
  4. A removed listener will not get another notification
  5. An added listener will receive its first notification immediately, although will not see all the nested changes going on (it is the last listener after all, so item (3) applies)
  6. As said, no copying or locking of lists

Here's a sample output with 5 listeners, where 0, 2 and 4 just register changes, and 1 uppercases a String and 3 will ensure the String contains 2 characters:

Notifying 0 of change from A to b
Notifying 1 of change from A to b
  (listener 1 uppercases "b")
  Notifying 0 of change from b to B
  Notifying 1 of change from b to B
Notifying 2 of change from A to B
Notifying 3 of change from A to B
  (listener 3 adds a character "B" -> "Bb")
  Notifying 0 of change from B to Bb
  Notifying 1 of change from B to Bb
  (listener 1 uppercases "Bb")
    Notifying 0 of change from Bb to BB
    Notifying 1 of change from Bb to BB
  Notifying 2 of change from B to BB
  Notifying 3 of change from B to BB
Notifying 4 of change from A to BB

As you can see, all changes make sense and the final listener only receives the full change.

@hjohn hjohn marked this pull request as draft April 5, 2023 01:09
@openjdk openjdk bot removed the rfr Ready for review label Apr 5, 2023
@hjohn
Copy link
Collaborator Author

hjohn commented Apr 5, 2023

Converted this one to draft, I no longer think this is the way to go.

See instead for a depth-first approach this PR: #1081

I think the new approach is much more solid, makes more sense, and is much better at avoiding unnecessary change listener calls. It's however a lot of new code...

@hjohn hjohn changed the title 8290310: ChangeListener events are incorrect or misleading when a nested change occurs (draf): ChangeListener events are incorrect or misleading when a nested change occurs Apr 6, 2023
@bridgekeeper
Copy link

bridgekeeper bot commented May 31, 2023

@hjohn This pull request has been inactive for more than 8 weeks and will be automatically closed if another 8 weeks passes without any activity. To avoid this, simply add a new comment to the pull request. Feel free to ask for assistance if you need help with progressing this pull request towards integration!

@openjdk openjdk bot added the merge-conflict Pull request has merge conflict with target branch label Jun 15, 2023
@bridgekeeper
Copy link

bridgekeeper bot commented Jul 26, 2023

@hjohn This pull request has been inactive for more than 16 weeks and will now be automatically closed. If you would like to continue working on this pull request in the future, feel free to reopen it! This can be done using the /open pull request command.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
merge-conflict Pull request has merge conflict with target branch
Development

Successfully merging this pull request may close these issues.

5 participants