Skip to content

WELD-2812 Improve Invoker exception messages#3133

Merged
manovotn merged 1 commit intoweld:masterfrom
Azquelt:invoker-exception-messages
Mar 19, 2025
Merged

WELD-2812 Improve Invoker exception messages#3133
manovotn merged 1 commit intoweld:masterfrom
Azquelt:invoker-exception-messages

Conversation

@Azquelt
Copy link
Contributor

@Azquelt Azquelt commented Mar 5, 2025

Some of the exception messages you can get from calling an Invoker with the wrong arguments are a bit obtuse and I wanted to look at providing a bit more information.

To do this, I'm doing extra checks before calling the method to ensure that:

  • Enough arguments have been passed in
  • Non-looked-up arguments have the correct type
  • Non-looked-up primitive arguments are not null
  • Non-looked-up instances have the correct type
  • The instance is not null if the method is non-static

Most of these checks have to be done before the argument array is spread to the actual arguments, since the MethodHandles implementation will throw an exception there if things are the wrong types.

If there are transformers, type checking is done against the input type of the transformer.

The null check for the instance is done after the transformers run because an instance transformer can convert a null to a real instance.

The null check for primitive arguments, however, has to be done before the argument array is spread, since the spread operation will fail if the arguments cannot be converted to the primitive type successfully. I think this is safe because we separately check that the types for an argument transformer match the method parameters, and if that's a primitive type then the argument transformer isn't able to return null there.

Performance

I'm doing all of these checks before method invocation. Running an invoke call to InvokableBean.ping 10 million times took 5.2 seconds before these changes and 5.7 seconds afterwards, so there is a performance impact there which we may want to look into further.

One other thought I had was that it might be possible to catch NullPointerException and ClassCastException and only do these checks in the case where one of those exceptions was thrown. If we find that there's a problem with the instance or arguments at that point, we can replace the exception thrown by the MethodHandles implementation. If all our checks pass we'd rethrow the original exception.

Examples

Not enough arguments:

Before:

java.lang.IllegalArgumentException: array is not of length 1
	at java.base/java.lang.invoke.MethodHandleStatics.newIllegalArgumentException(MethodHandleStatics.java:167)
	at java.base/java.lang.invoke.MethodHandleImpl.checkSpreadArgument(MethodHandleImpl.java:590)
	at org.jboss.weld.invokable.InvokerImpl.invoke(InvokerImpl.java:18)

After:

org.jboss.weld.exceptions.IllegalArgumentException: WELD-002017: Cannot invoke public java.lang.String io.openliberty.cdi41.internal.fat.invokers.app.InvokedBean.basicMethod(int) because 1 arguments were expected but only 0 were provided
	at org.jboss.weld.invokable.InvokerValidationUtils.argCountAtLeast(InvokerValidationUtils.java:67)
	at org.jboss.weld.invokable.InvokerImpl.invoke(InvokerImpl.java:18)

Arguments array is null when arguments are required

Before:

java.lang.NullPointerException: null array reference
	at java.base/java.lang.invoke.MethodHandleImpl.checkSpreadArgument(MethodHandleImpl.java:581)
	at org.jboss.weld.invokable.InvokerImpl.invoke(InvokerImpl.java:18)

After:

org.jboss.weld.exceptions.IllegalArgumentException: WELD-002017: Cannot invoke public java.lang.String io.openliberty.cdi41.internal.fat.invokers.app.InvokedBean.basicMethod(int) because 1 arguments were expected but only 0 were provided
	at org.jboss.weld.invokable.InvokerValidationUtils.argCountAtLeast(InvokerValidationUtils.java:67)
	at org.jboss.weld.invokable.InvokerImpl.invoke(InvokerImpl.java:18)

^I wasn't sure whether this one should still be a NullPointerException or whether we should treat it the same as when the user doesn't pass enough arguments.

Primitive argument is null:

Before:

java.lang.NullPointerException: Cannot invoke "java.lang.Number.intValue()" because the return value of "sun.invoke.util.ValueConversions.primitiveConversion(sun.invoke.util.Wrapper, java.lang.Object, boolean)" is null
	at java.base/sun.invoke.util.ValueConversions.unboxInteger(ValueConversions.java:81)
	at org.jboss.weld.invokable.InvokerImpl.invoke(InvokerImpl.java:18)

After:

java.lang.NullPointerException: WELD-002019: Cannot invoke public java.lang.String io.openliberty.cdi41.internal.fat.invokers.app.InvokedBean.basicMethod(int) because parameter 1 is a primitive type but the argument is null
	at org.jboss.weld.invokable.InvokerValidationUtils.argumentsHaveCorrectType(InvokerValidationUtils.java:99)
	at org.jboss.weld.invokable.InvokerImpl.invoke(InvokerImpl.java:18)

Primitive argument has the wrong type:

Before:

java.lang.ClassCastException: java.lang.Object incompatible with java.lang.Number
	at java.base/sun.invoke.util.ValueConversions.primitiveConversion(ValueConversions.java:247)
	at java.base/sun.invoke.util.ValueConversions.unboxInteger(ValueConversions.java:81)
	at org.jboss.weld.invokable.InvokerImpl.invoke(InvokerImpl.java:18)

After:

java.lang.ClassCastException: WELD-002018: Cannot invoke public java.lang.String io.openliberty.cdi41.internal.fat.invokers.app.InvokedBean.basicMethod(int) because argument 1 has type class java.lang.Object which cannot be cast to int
	at org.jboss.weld.invokable.InvokerValidationUtils.argumentsHaveCorrectType(InvokerValidationUtils.java:102)
	at org.jboss.weld.invokable.InvokerImpl.invoke(InvokerImpl.java:18)

Instance has the wrong type:

Before:

java.lang.ClassCastException: Cannot cast java.lang.Object to io.openliberty.cdi41.internal.fat.invokers.app.InvokedBean
	at java.base/java.lang.Class.cast(Class.java:3264)
	at org.jboss.weld.invokable.InvokerImpl.invoke(InvokerImpl.java:18)

After:

java.lang.ClassCastException: WELD-002016: Cannot invoke public java.lang.String io.openliberty.cdi41.internal.fat.invokers.app.InvokedBean.basicMethod(int) because the instance passed to the Invoker has type class java.lang.Object which cannot be cast to class io.openliberty.cdi41.internal.fat.invokers.app.InvokedBean
	at org.jboss.weld.invokable.InvokerValidationUtils.instanceHasType(InvokerValidationUtils.java:35)
	at org.jboss.weld.invokable.InvokerImpl.invoke(InvokerImpl.java:18)

Null instance for a non-static method:

Before:

java.lang.NullPointerException: Cannot invoke "java.lang.invoke.MethodHandle.invoke(java.lang.Object, java.lang.Object[])"
	at org.jboss.weld.invokable.InvokerImpl.invoke(InvokerImpl.java:18)

After:

java.lang.NullPointerException: WELD-002015: Cannot invoke public java.lang.String io.openliberty.cdi41.internal.fat.invokers.app.InvokedBean.basicMethod(int) because the instance passed to the Invoker was null
	at org.jboss.weld.invokable.InvokerValidationUtils.instanceNotNull(InvokerValidationUtils.java:50)
	at org.jboss.weld.invokable.InvokerImpl.invoke(InvokerImpl.java:18)

@Azquelt Azquelt requested a review from manovotn as a code owner March 5, 2025 15:15
@manovotn
Copy link
Member

manovotn commented Mar 6, 2025

Thank you for the PR.
I'll try to review this in the coming days. I also need to refresh my MethodHandle-fu in terms of what we ended up adding to Weld :)

CC @Ladicek in case you are interested; I know you authored some of these parts.

@Ladicek
Copy link
Contributor

Ladicek commented Mar 6, 2025

I generally agree that better exceptions are better, but the performance impact is exactly why I made the specification relatively vague on this.

@manovotn
Copy link
Member

manovotn commented Mar 6, 2025

I generally agree that better exceptions are better, but the performance impact is exactly why I made the specification relatively vague on this.

Yes, I did have some simple perf tests with Weld and invokers where I tested the initial drafts.
I intend to update the branch and merge it into master in the coming day - https://github.com/weld/weld-core-benchmarks/tree/invokableMethods
Then I can add some test and see what the actual difference is.

Although the way Andrew wrote it is very nice (another method handle basically) so I assume the impact will be barely noticeable in which case it is probably just fine 🤷

@manovotn
Copy link
Member

manovotn commented Mar 7, 2025

I'm doing all of these checks before method invocation. Running an invoke call to InvokableBean.ping 10 million times took 5.2 seconds before these changes and 5.7 seconds afterwards, so there is a performance impact there which we may want to look into further.

I have tried to measure this with some JMH benchmarks that we already have in place and also tried adding some others (not commited) [benchmark repo link]. The results show mostly negligible differences - and by that I mean <1% difference so long as the method uses lookup for some of its parts or does some noticeable work.
Without less actual work in the method, it obviously becomes more and more noticeable - similar to the numbers you wrote.

I am not sure which InvokableBean do you refer in this case? I.e. what does the method actually do?

One other thought I had was that it might be possible to catch NullPointerException and ClassCastException and only do these checks in the case where one of those exceptions was thrown. If we find that there's a problem with the instance or arguments at that point, we can replace the exception thrown by the MethodHandles implementation. If all our checks pass we'd rethrow the original exception.

That's neat idea, it would eliminate the overhead - care to give it a go?

^I wasn't sure whether this one should still be a NullPointerException or whether we should treat it the same as when the user doesn't pass enough arguments.

Doesn't need to be NPE, but the exception message should probably say that null was passed in instead of actual array or args?

@Azquelt
Copy link
Contributor Author

Azquelt commented Mar 7, 2025

I am not sure which InvokableBean do you refer in this case? I.e. what does the method actually do?

Sorry, I mixed up my tests. I ran the test against SimpleBean.ping from org.jboss.weld.tests.invokable.common which appends an int to a String.

One other thought I had was that it might be possible to catch NullPointerException and ClassCastException and only do these checks in the case where one of those exceptions was thrown.

That's neat idea, it would eliminate the overhead - care to give it a go?

I'll give this a try

^I wasn't sure whether this one should still be a NullPointerException or whether we should treat it the same as when the user doesn't pass enough arguments.

Doesn't need to be NPE, but the exception message should probably say that null was passed in instead of actual array or args?

I'll add a message for this as a separate case.

@manovotn manovotn changed the title Improve Invoker exception messages WELD-2812 Improve Invoker exception messages Mar 8, 2025
@manovotn
Copy link
Member

manovotn commented Mar 8, 2025

FTR, I've created a tracking issue for this and changed the title of this one to include issue number - https://issues.redhat.com/browse/WELD-2812
We can add this fix to both, Weld 6 and 7.

@Azquelt Azquelt force-pushed the invoker-exception-messages branch from ba10eed to 65d9c2b Compare March 11, 2025 17:40
@Azquelt
Copy link
Contributor Author

Azquelt commented Mar 11, 2025

I've updated this PR so that it checks the instance and arguments only if a NullPointerException, ClassCastException or IllegalArgumentException is thrown while invoking the method.

Method handles does not allow the equivalent of multiple catch blocks on a single try, so we have several nested try-catch blocks to catch each exception. I've reorganised validation checks slightly to ensure that we don't throw an exception from one of the inner catch blocks that would be immediately caught by a surrounding catch block.

I've put the new changes in a new commit for comparison, but they can all be squashed before it's merged.

A quick sniff test suggests that the performance is much closer to the original and quite a bit improved from the previous approach of doing the additional checks every time before the method is called.

I did notice that in my original approach, I'd used filterArguments in most cases to call the validation methods but foldArguments may have been more appropriate.

@manovotn
Copy link
Member

Sorry for the delay, I was sick and out of office.
I need to do some catching up now but this is definitely on my radar and I intend to check your changes sometime this week and rerun the benchmarks as well 👍

Copy link
Member

@manovotn manovotn left a comment

Choose a reason for hiding this comment

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

This is looking great @Azquelt!
I have done some perf testing as well and it is essentially indistinguishable from the state without validation 👍

I've added a few minor comments that I spotted while reviewing.
Feel to squash the commits into one as well.

MethodHandle cceCatch = MethodHandles.throwException(mh.type().returnType(), ClassCastException.class);
cceCatch = MethodHandles.collectArguments(cceCatch, 1, checkTypes);

mh = mh.asType(mh.type().changeParameterType(0, Object.class)); // Defer the casting the instance to its expected type until we're inside the ClassCastException try block
Copy link
Member

Choose a reason for hiding this comment

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

Do I understand it correctly that this is needed strictly because the adapter method handle (cceCatch) must have the exact same type as the target method handle? I am a bit surprised this doesn't cause issues when invoking the method handle for the original (user defined) method.

Also, a nitpicker note - the comment has a superflous the in it :)

Copy link
Contributor Author

@Azquelt Azquelt Mar 19, 2025

Choose a reason for hiding this comment

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

Do I understand it correctly that this is needed strictly because the adapter method handle (cceCatch) must have the exact same type as the target method handle?

Yes, this is a requirement of MethodHandles.catchException.

I am a bit surprised this doesn't cause issues when invoking the method handle for the original (user defined) method.

I think this works because MethodHandle.invoke will attempt to adapt the arguments to the types expected by the method handle, in the same way that MethodHandle.asType would.

Without this line which changes the parameter type, the final MethodHandle will have:

  • return type the same as the user's method
  • first parameter type equal to the declaring class of the user's method (or Object for static methods)
  • second parameter of Object[]

This will cast the instance to the expected type when invoke is called, so when we're trying to catch and handle the ClassCastException ourselves, we need to ensure that the first parameter has type Object and that the cast to the expected type is done within our catch block.

Otherwise, it would also be possible satisfy the requirement that parameter types match exactly by adapting the cceCatch method handle to match the user's method:

            MethodHandle cceCatch = MethodHandles.throwException(mh.type().returnType(), ClassCastException.class);
            cceCatch = MethodHandles.collectArguments(cceCatch, 1, checkTypes);
            cceCatch = cceCatch.asType(cceCatch.type().changeParameterType(1, mh.type().parameterType(0)));

            mh = MethodHandles.catchException(mh, ClassCastException.class, cceCatch);

However, this results in the invoke call doing the cast which means we can't catch the exception.

Copy link
Member

Choose a reason for hiding this comment

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

I think this works because MethodHandle.invoke will attempt to adapt the arguments to the types expected by the method handle, in the same way that MethodHandle.asType would.

Ah, right, that's it.
I keep thinking along the lines of invokeExact which is what probably wouldn't work.

Thanks for detailed explanation.

If an invoker invocation causes a ClassCastException,
NullPointerException or IllegalArgumentexception, validate the instance
and arguments to see if the exception was caused by passing invalid
values. If so, discard the original exception and throw a new exception
with more information.

Doing these checks only when the invocation throws an exception avoids
the overhead of doing these checks before every successful invocation.
@Azquelt Azquelt force-pushed the invoker-exception-messages branch from 65d9c2b to 5f393ee Compare March 19, 2025 16:27
@Azquelt
Copy link
Contributor Author

Azquelt commented Mar 19, 2025

I think I've addressed all the review comments and have squashed it ready to merge.

@manovotn
Copy link
Member

Thanks for contributing :)

@manovotn manovotn merged commit ee9f196 into weld:master Mar 19, 2025
12 checks passed
@Azquelt Azquelt deleted the invoker-exception-messages branch March 19, 2025 17:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants