Resolve ambiguity in function pointer resolution. - #1143
Conversation
Needed so a type outside `grain` can name it; `mod program` is private.
`is_poolable` claimed to rule out `Shared` but never asked: `is_array` and friends see through the cell, so a shared array was pooled by cloning the `Rc`, leaving the pool aliasing a cell the host can still write to. Nothing a `Program` owns may alias mutable state — that is what keeps the reference graph among programs acyclic.
`FnPtrType::Compiled` carries the program and the function's index, so a
pointer handed to a native can answer its own arity instead of leaving
the native to guess from a name.
Only where the caller gave us shared ownership, and only where exactly
one compiled function bears the name — `MakeClosure` also serves
`Fn("f")` folded to a constant, and a name with two arities has no one
shape to declare.
A native taking a callback offers a menu of argument shapes and picks by the pointer's declared arity. A name-only pointer declared none, so dispatch fell through to `call_raw`, which splices the receiver in at index 0 — right for `map` and `filter`, wrong for everything else. `reduce` wants the element after the accumulator; `Map` hands the key as an argument and the value as the receiver; `for_each` wants the index, with the element as `this`. The first raised, the other three returned the wrong answer silently. The menu is now shared between `Script` and `Compiled`, differing only in whether the receiver may be handed over as `this` — a script body binds it whether or not it reads it, a compiled chunk reached through a wrapper has no slot for it.
`eval_with_scope` borrows the program, so there is no ownership to put in a pointer and it stays a bare name — which the existing test already covers. `eval_with_callbacks` has some, and the pointer carries it.
`eval_with_scope` borrows the program, so a pointer it builds cannot declare its chunk's arity. That is safe only because a native reaches a compiled chunk solely through the wrappers, and the run that installs none is the run that hands out the weaker pointer — so such a call dies on the name instead. Extended to every shape the wrappers path had to be taught, not just `map`: one of them resolving here is the silent-wrong-answer case.
|
Yes, I found that too. Because VM scripted functions are actually registered as a native callback, it threw off Rhai's logic. Therefore we have problems with functions like Also it necessitates very convoluted things like I have been thinking of introducing a new VM-callback type. You beat me to it! |
There was a problem hiding this comment.
In this case, can we get rid of makes_fn_pointers and takes_this, thereby simplifying the VM a whole lot?
At least I believe makes_fn_pointers would have no need to exist, since its whole existence is due to needing to send the callback eval back to the AST walker. Also, I would consider it quite dangerous, as functions may create FnPtr's as their return values and the VM would have no way to predict whether function pointers will ever exist inside a program.
Also, there should now be no distinction between run and run_with_callback, as we can always provide a wrapper module by default... This would simplify the VM API...
| args2.insert(move_this_ptr_to_args, this_ptr.clone()); | ||
| } | ||
| return self.call_raw(ctx, None, args2); | ||
| if let Some((num_params, binds_this)) = self.declared_shape() { |
There was a problem hiding this comment.
I suggest we move this mechanism into call_raw itself, instead of inside _call_with_extra_args.
That is because call_raw is the main work-horse, while _call_with_extra_args is only intended for flexible-args type of callbacks. Most callbacks do not use those, but they'd still need to distinguish a VM-registered callback (because of where to put the this pointer).
Therefore most callbacks will actually land back into call_raw and you have the same problem that declared_shape is designed to fix. So move it to call_raw instead by removing the this_ptr if it is not necessary.
In fact, it can simply be like this:
// Inside `call_raw`...
// Embedded native Rust function?
match self.typ {
FnPtrType::Native(ref func) => {
:
}
#[cfg(all(feature = "grain", not(feature = "no_function")))]
FnPtrType::Compiled { ref program, index } => {
// Do we have to search the functions every time?
// Can't we just store a `bind_this` boolean here?
if !program
.functions()
.get(index as usize)
.map(|f| f.takes_this)
.unwrap_or(false)
{
// The VM-compiled function has no need for `this`,
// so remove `this_ptr`!
this_ptr = None;
}
}
_ => ()
}
// If `FnPtrType::Compiled` stores a boolean `bind_this` instead,
// it becomes this one-liner: FnPtrType::Compiled { bind_this: false } => this_ptr = None_call_with_extra_args can revert back to previous, because all that's need to handle the situation would already be done.
There will also be no need for declared_shape as it can simply be inlined into the change as above. So the changes to fn_ptr.rs would actually be minimal.
There was a problem hiding this comment.
The problem is that the way the arguments are laid out with args and extras, it can be ambiguous as to what type of resolution should occur, thus the logic for FnPtrType::Script. This PR essentially just applies the same logic to VM-managed functions. Your solution would result in incorrect behavior because it doesn't have the knowledge of the preferred function call arrangement from the native call.
There was a problem hiding this comment.
Well, I think it actually can be done. And we can also do away with the entire needs_walker and falling back to AST-walking for closure functions using the this pointer.
In callback::wrappers, right now you skip all functions using this for this reason, falling back to including the AST for walker evaluation.
With FnPtrType::Compiled you don't have to. You can simply just register that function. And there will no longer be the need to needs_walker etc.
You just register two different types of wrappers: One without this usage, and one with this. The version with this usage (say invoke_with_this) simply pops the first argument as the this pointer, then go on with the rest.
There was a problem hiding this comment.
it can be ambiguous as to what type of resolution should occur
Actually it isn't ambiguous. There is only one type of function VM-generated, which is a script. It can never be Rust-native. Therefore, it always would expect the this pointer in the zeroth position, just like Script.
The only difference whether Rhai would know to put that this_ptr in when the VM needs it, or it doesn't if the VM doesn't want it (so it won't end up with an extra argument).
That's why I'm proposing changing this to a simple bool.
There was a problem hiding this comment.
@ImTheSquid Actually I have a version that does exactly this:
- Removed
needs_walker - Removed
libfromProgramandParts - Only has one
bind_this: boolinFnPtrType::Compiled - No falling back to AST-walking
- Successfully handle callbacks in the VM that uses
this - Successfully handle callbacks in the VM not using
this
I'm trying to consolidate all of this such that it doesn't matter whether the VM function uses this...
Do you know how I can put a PR on top of this PR?
There was a problem hiding this comment.
Removing arity from the pointer type results in a significant performance hit of like 50%. That needs to stay in. I'm also working on my own fixes and I am taking some of your suggestions, give me a bit please.
There was a problem hiding this comment.
Gotcha. Just doing some experimentation on my side as well.
I don't see how the arity issue should affect performance. In fact it should simplify the situation.
That's because a compiled chunk has no arity conflicts. You can consider them to always be arity + 1, which are the arguments plus a this. It doesn't matter that the this pointer is not used. How I do it right now is, if it doesn't need it, Rhai binds it to a dummy temp with () in it.
So the arity issue is solved. The only thing remaining is to tell Rhai that this is actually a scripted functions instead of a native function. Thats what Compiled tells it.
There was a problem hiding this comment.
I mean I ran a performance benchmark and it was significantly reduced, you can run the bench too and see what results you get. Sometimes this is unbound iirc so you can't just assume.
There was a problem hiding this comment.
Hhhhmmm.... let me run that again. I ran it before and the difference was within error bars...
Right now in the experiment I cheated it: if this is unbound, then it'll be sent to a dummy with () as value.
Technically speaking, it should be returning with an ErrorUnboundThis...
| /// (`grain/vm/callback.rs`) — so handing one to a chunk that does not take | ||
| /// it means calling a wrapper with an argument too many. | ||
| #[must_use] | ||
| fn declared_shape(&self) -> Option<(usize, bool)> { |
There was a problem hiding this comment.
Perhaps this should be declared as impl FnPtrType?
Then it is self.typ.declared_shape()....
There was a problem hiding this comment.
In fact, I'd suggest removing this altogether when we move the logic inside call_raw, which simplifies the whole thing.
There was a problem hiding this comment.
See my comment on the move
|
@ImTheSquid #1144 is there as a proof of concept. |
`Fn("f")` means whatever `f` is at the arity the caller settles on, which Rhai
finds by trying shapes until one dispatches. A pointer carrying its chunk's
arity settled it earlier, reaching the compiled function where the walker
reached a host's one of the same name — so only an anonymous function gets one,
whose name cannot collide.
Whether a chunk can be handed a receiver moves to `call_raw`, the entry every
caller reaches: a host's native can hand one over without the argument shaping
above it, and a chunk that ignores `this` is reached by a wrapper one argument
narrower. One rule in one place, so what is left is `declared_params`.
The corpus gains the callback shape a receiver-popping wrapper would answer
instead of raising, which is why `callback.rs` still turns those chunks down.
|
@ImTheSquid I've been thinking a bit more on this topic. We might have been going at in from the wrong direction. The Root of the ProblemThe question is: what caused this call-shape problem? It is the distinction between a scripted function and a native Rust function. In particular, a native Rust function cannot accept the Due to the fact that a native Rust function cannot accept the receiver of the function call, Rhai was adapted to allow putting the receiver as the first In conclusion: the root of the problem is inconsistent treatment of function call shape between scripted and native functions. Our ProblemOur problem arose because the VM registers a bunch of callbacks. They are native functions, but they redirect back to the VM running scripted functions. This mismatch is causing all sorts of problems, because Rhai simply doesn't know how to treat this. This behavior muddles the hard distinction between scripted and native functions assumed all over Rhai code. Maybe...Maybe what we need is something different. Why register a native function for the callback? Why not register a scripted function instead? The only problem stopping us is that we cannot provide an AST for the scripted function. So, what we create a new AST node type just for this? Call it
|
Grain had an issue where its use of
Normalfunction pointers led to resultion ambiguity that caused incorrect behavior. For example:Before, Grain would bind
itothis, resulting in an incorrect value fortof 6 instead of 3. Now this no longer happens due to my introduction ofFnPtrType::Compiled, which properly tracks function pointer information for Grain calls.