AcademySoftwareFoundation / OpenTimelineIO

Open Source API and interchange format for editorial timeline information.
http://opentimeline.io
Apache License 2.0
1.44k stars 281 forks source link

Error handling fixes #1060

Closed darbyjohnston closed 2 years ago

darbyjohnston commented 3 years ago

As pointed out by @gfgit, there is an issue with the error handling in the new C++ functions Composition::child_at_time(), Composition::children_in_range(), Composition::children_if(), and SerializableCollection::children_if().

The code currently looks like this:

if (!error_status) {
...
}

But should look like this (similar to the surrounding code):

if (*error_status) {
...
}

We don't need to check whether error_status is null since the API assumes that it is a valid pointer.

meshula commented 3 years ago

Repeating the notes on the PR here. The bug being fixed isn't a bug...

The reason the null checks were on the error_status was because we pass in a pointer, that can be null, which causes crashes upon dereference. Clearly that's confusing as there's ambiguity as to whether the error status is supposed to be checked, or if the error status is merely a place to write.

This code is meant to write to error_status, if error_status was applied. Not, if error_status pointer was supplied return as if an error occurred.

I believe the real issue, to @gfgit's original review, is that we are using a C style idiom in a C++ interface. Having a pointer to a place to store any errors in the interface is the root source of the issue. I'm open to a new interface proposal.

darbyjohnston commented 3 years ago

I think there are two issues here; first, the PR does fix a legit bug in the error handling of the new functions I added. The changes in the PR will fix the functions so that they properly catch errors and return from the function when an error occurs.

The second issue is about passing error_status as a pointer in the C++ API; if it's expected to always be a valid pointer that should probably be documented. Or maybe change the API so that error_status is passed as a reference so there's no ambiguity.

meshula commented 3 years ago

I'm not comfortable with the notion that !error_status was interpreted by readers as evaluating a status, nor am I comfortable with *error_status evaluating status. I'd prefer an optional as the only variant that actually expresses the intent that there may be no ErrorStatus. Also I'd prefer that asterisk is not the test, but an explicit test, something like

void someFunc(..., optional<ErrorStatus> es) {
    if (es && es->error_occurred())
        return;
}

Anything else, and we're asking people to memorize ambiguous semantics.

I'm not fond of ErrorStatus& unless the pointer is changed to a reference across the entire API. But then, we have a C++ ism that doesn't match to our wrapping languages except in an unsafe way. In other words, in C, we have to pass by reference, and we are back to square one of broken semantics. Swift, Python, and Java only magnify the mismatch.

darbyjohnston commented 3 years ago

The asterisk isn't even the actual test, it dereferences the pointer so that ErrorStatus::operator bool() can be used to test whether the outcome is != OK. I think that combination of pointers and operators is maybe a bit confusing.

Using an optional is interesting, in that case would it be defaulted to null for all of the functions? The downside is that changing the API would require all C++ code that currently uses OTIO to be updated. Maybe not an unreasonable thing to do before v1.0. :)

meshula commented 3 years ago

Yes, it is the bool() operator that I am explicitly objecting to. Sadly, on further thought, passing an optional has the same problem I mentioned versus language bindings. For now, I'd be satisfied with a null pointer test followed by a test that is not a bool() conversion.

darbyjohnston commented 3 years ago

If we are allowing error_status to be null, how about defaulting it to null for all functions? That way it's self-documenting that it can be null, and allows a user of the API to ignore it if they don't want to do error checking.

ssteinbach commented 3 years ago

Should we have a macro for checking this? something that wraps up the null check and returns a default error status thing if it is a nullptr?

ssteinbach commented 3 years ago

Thinking about it some more, if we used a std::pair to return {error, value} instead of error_status* + mutable reference as an argument, that might work better

darbyjohnston commented 3 years ago

Maybe instead of a macro, a static function on ErrorStatus:

static bool is_ok(ErrorStatus* es)
{
    return es && Outcome::OK == es->outcome;
}

Used like:

if (!ErrorStatus::is_ok(es))
{
    // Handle error...
}

That's an interesting idea about std::pair; so the function:

TimeRange range_of_child_at_index(int index, ErrorStatus* error_status) const;

Would look like this instead?

std::pair<TimeRange, ErrorStatus> range_of_child_at_index(int index) const;

That might get a bit complex with some of the other functions:

std::pair<std::pair<optional<RationalTime>, optional<RationalTime>>, ErrorStatus>
    handles_of_child(Composable const* child) const;
ssteinbach commented 3 years ago

Right. I would probably put the error first in the pair. And you could wrap that in a macro:

#define ERROR_WRAPPER(type) std::pair<ErrorStatus, type> so you would have: ERROR_WRAPPER(std::pair<optional<RationalTime>, optional<RationalTime>>) handles_of_child(Composable const* child) const;

maybe? just a thought. @meshula, any further comments? We could discuss it at the TSC meeting if people are interested in a change like this. I'm not sure if there are other C++ libraries out there with similar patterns that we could look at, but this came out of a conversation @meshula and I were having in the context of the zig programming language.

meshula commented 3 years ago

Another variant is to not use a pair, but an explicit template because we don't actually want the polymorphic nature of pair. e.g.

template<typename T> struct CheckedValue {
    explicit CheckedValue(ErrorStatus es) : status(es) { if (es == ErrorStatus::NoProblemo) throw Whatever; } }
    explicit CheckedValue(const T& val) : status(ErrorStatus::NoProblemo), value(val) {}
    explicit CheckedValue(T&& val) :  status(ErrorStatus::NoProblemo), value(std::move(val)) {}
    const ErrorStatus status;
    const T& checked_value() const { if (status != ErrorStatus::NoProblemo) throw Whatever; return value; }
private:
    const T value;
};
darbyjohnston commented 3 years ago

I wonder if that would make things more difficult for the language wrappings? It would also require updating all of the bindings and C++ client code which is a fair bit of work.

After thinking about it some more, my preference would be keeping the API mostly the same but with a couple changes to help improve the clarity:

I think those two changes would help a lot and also be relatively easy to make. It also shouldn't affect the bindings or C++ client code.

meshula commented 3 years ago

I don't expect it to be difficult to bind per se. The advantage is that modern languages generally have multiple returns, so the class would map onto a multiple return. It would be a tedious and time consuming change however!

Your latter suggestion is the lightest touch, and gets rid of the operator bool, so I'd personally be fine with that.

darbyjohnston commented 3 years ago

I updated the PR with my proposed changes, and also converted it to a draft since we are still iterating on the issue.

I didn't remove ErrorStatus::operator bool yet, since that would require updating all of the code and I just wanted to get feedback about the proposed changes. I just updated the code in the original PR to use the new member function instead of the operator.