cpython icon indicating copy to clipboard operation
cpython copied to clipboard

gh-119004: fix a crash in equality testing between `OrderedDict`

Open picnixz opened this issue 1 year ago • 5 comments

This patch is based on https://github.com/python/cpython/issues/119004#issuecomment-2108272841 but some bits needed to be added here and there.

  • Issue: gh-119004

📚 Documentation preview 📚: https://cpython-previews--121329.org.readthedocs.build/

picnixz avatar Jul 03 '24 12:07 picnixz

I don't see the need to change the pure Python version. Since it won't segfault, there is no need for this elaborate defense against something that doesn't arise in normal practice.

rhettinger avatar Jul 03 '24 14:07 rhettinger

Since it won't segfault, there is need for this elaborate defense against something that doesn't arise in normal practice.

Well.. without those changes, the tests would be broken (no exception would be raised actually). And the C implementation says:

This implementation is necessarily explicitly equivalent to the pure Python OrderedDict class in Lib/collections/__init__.py.

picnixz avatar Jul 03 '24 14:07 picnixz

Well.. without those changes, the tests would be broke

You can either make the new tests for the C version only, or you can make the C version emulate what the Python version does (no exception).

no exception would be raised actually

I'm fine with that. We made that decision long ago and it has worked out fine in practice.

Our only goal in this PR is make sure the C version won't segfault. There should be no other user visible API change. Nor should the pure python reference implementation change.

This implementation is necessarily explicitly equivalent to the pure Python OrderedDict class in Lib/collections/init.py.

Add, "except where it is necessary to protect the C code from a segfault."

rhettinger avatar Jul 03 '24 16:07 rhettinger

you can make the C version emulate what the Python version does (no exception).

With this approach, this means I need duplicate the items buffer the entire nodes in advance otherwise calling clear() in between would free nodes that are no more available... I'm worried about performances but I'll try to benchmark and see the impact.

Our only goal in this PR is make sure the C version won't segfault. There should be no other user visible API change.

Actually, the Python implementation does segfault (well it's a segfault in the form of an AttributeError but it's essentially the same issue I think). The Python implementation of __delitem__ does:

    def __delitem__(self, key, dict_delitem=dict.__delitem__):
        'od.__delitem__(y) <==> del od[y]'
        # Deleting an existing item uses self.__map to find the link which gets
        # removed by updating the links in the predecessor and successor nodes.
        dict_delitem(self, key)
        link = self.__map.pop(key)
        link_prev = link.prev
        link_next = link.next
        link_prev.next = link_next
        link_next.prev = link_prev
        link.prev = None  # <- this has side-effects in __reversed__
        link.next = None  # <- this has side-effects in __iter__

And in __eq__ we do:

	return dict.__eq__(self, other) and all(map(_eq, self, other)))

The following test:

        count = 0
        class ChangeExternItem:
            def __eq__(self, other):
                nonlocal count
                if count == 1:
                    del lhs[self]
                count += 1
                return True
            def __hash__(self):
                return -1

        l1, r1 = ChangeExternItem(), ChangeExternItem()
        lhs = self.OrderedDict(dict.fromkeys((0, l1)))
        rhs = self.OrderedDict(dict.fromkeys((0, r1)))
		# AttributeError: 'NoneType' object has no attribute 'key'
		lhs == rhs

Now, the first dict.__eq__(lhs, rhs) call would trigger del lhs[self] which itself triggers OrderedDict.__delitem__, so in __iter__, after you're done getting the key of some item (here, l1), you would then do .next. However this one will be None (so it's not self.__root) and you would end up doing yield curr.key which fails!

So I still think that the Python implementation should be protected the same the C implementation is. I didn't catch the exception in .clear() (don't know why though) but if you delete a key during the iteration, then you end up having some error like that.

In summary the Python implementation has also a bug which can be prevented using the current guards.

picnixz avatar Jul 04 '24 12:07 picnixz

Actually, the Python implementation does segfault

I was not able to reproduce this. The code in the referenced issue terminated normally, giving the output:

0
1
cleared l
True

(well it's a segfault in the form of an AttributeError but it's essentially the same issue I think).

That is not as severe as a segfault. An exception is a normal way for functions and methods to die when fed bogus inputs.

I'll look at this more when I get back from vacation. In the mean time, think about the least invasive, least behavior changing way to avoid the C code segfault. You've explored state tracking (an approach we intentionally decided not to do); now look for less impactful approaches.

Keep in mind, OrderedDict is mostly obsolete and not much used anymore. We're trying to leave it as stable as possible. We definitely don't want a segfault, but we want to avoid redesign and behavior changes. This is doubly true in this case where we're solving a problem that doesn't seem to have ever arisen in practice.

rhettinger avatar Jul 04 '24 16:07 rhettinger

I was not able to reproduce this. The code in the referenced issue terminated normally, giving the output:

Yes, that's what I thought as well. Actually, the .clear() is not the issue here (probably because how it's actually implemented) but the problem is with the test example I gave with the del.

That is not as severe as a segfault

Maybe I should have phrased differently in the sense that the reason why such AttributeError arises is the same reason why it segfaults (at least for this specific test case I put).

You've explored state tracking (an approach we intentionally decided not to do); now look for less impactful approaches.

I see that I forgot to put that in my previous comment but I've thought of copying the keys before calling == but it comes at a high cost which I'd prefer not to do (i,e, making the C version equivalent to list(self.keys()) == list(other.keys())). And even if I were to this, I don't know what to return as a result of the comparison. For instance, if calling == has a side effect which changes a value that would previously be compared equal, what should I do? in some sense, the keys are equal and beforehand we already verified that the values are equal, but now it's no more the case, so what should self == other return in this case? By catching any mutation, at least I don't need to answer that question (or make a choice).

I'll try to come up with other ideas (it's something I didn't thought of, but maybe I could try to see how dicts are compared in general as well and try to apply that logic here, or see how other languages deal with that issue).

Keep in mind, OrderedDict is mostly obsolete and not much used anymore. We're trying to leave it as stable as possible

Oh don't worry about it, I think it should be the focus. I can suggest fixing the C implementation but leaving the Python implementation untouched and add a comment saying that mutating the operands may result in undesired side-effects and unexpected exceptions (at least, people would know that we know about the behaviour). One thing I would like to know is whether you want the Python implementation to possibly behave differently than the C implementation (at least, in the handling of errors).

picnixz avatar Jul 05 '24 16:07 picnixz

Would not test be simpler if you inline the code of setup() and teardown()?

I'll make a commit with this approach and see how it renders. But yes, it might be easier to review.

EDIT: It's much simpler now.

I andrestand that you want to test many different cases, but are they all necessary

I think I can remove the asymmetric cases now (or the symmetric ones maybe).

picnixz avatar Jul 22 '24 10:07 picnixz

@rhettinger Friendly ping in case you forgot about this PR

picnixz avatar Aug 01 '24 07:08 picnixz

At the sprint, I discussed this briefly with Eric Snow (the original author). He concurred that the pure python code should not change, and he had no objections to adding the state counter for the C version.

rhettinger avatar Sep 23 '24 19:09 rhettinger

Hum, do we backport it to 3.12 and 3.13? (AFAICT, this is a bug fix so there should be a backport)

picnixz avatar Sep 25 '24 09:09 picnixz

Thanks @picnixz for the PR, and @rhettinger for merging it 🌮🎉.. I'm working now to backport this PR to: 3.13. 🐍🍒⛏🤖

miss-islington-app[bot] avatar Sep 25 '24 10:09 miss-islington-app[bot]

Thanks @picnixz for the PR, and @rhettinger for merging it 🌮🎉.. I'm working now to backport this PR to: 3.12. 🐍🍒⛏🤖

miss-islington-app[bot] avatar Sep 25 '24 10:09 miss-islington-app[bot]

GH-124507 is a backport of this pull request to the 3.13 branch.

bedevere-app[bot] avatar Sep 25 '24 10:09 bedevere-app[bot]

GH-124508 is a backport of this pull request to the 3.12 branch.

bedevere-app[bot] avatar Sep 25 '24 10:09 bedevere-app[bot]