-
Notifications
You must be signed in to change notification settings - Fork 79
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
Still not sound: Gradual typing vs. opportunistic decoding #141
Comments
Hm, let me see. Before C itself gets changed to the extended signature types and upgraded, it will keep ignoring the new optional args & results and not pass anything on to bar. However, if you do try to change C to use the new types, you will probably get something like a warning that the call bar(baz()) uses unsafe subtyping. In Motoko, you wouldn't even be able to compile it anymore, and would be forced to change the program to pass null explicitly for the new parameter. (The example is a bit odd in that it composes two calls over a unit value, which is a bit pointless; but you can of course enrich the example such that there is at least one preexisting result/arg involved). So unless I am missing something, this kind of case can only break if the general opt subtyping was allowed in the source language itself -- which would alway be unsound regardless of Candid, unless subtyping was coercive and coerced to null in that case. Which would also fix the problem. Am I missing something? Is there a way to construct this example with a sound source language? Maybe by going one order higher? (Edit: Don't think that's possible, due to the property that we do not have "covert channels", so never pass on unknown values.) |
Oh, right, I was simplifying this too much. (I think I made the same mistake when building counter examples many times so far…) I need to make it more higher order: Counter example (2nd try)Let me try to decompose this into two steps:
For 1., let me try to construct a situation where a service Preparation:
If we pass Now we upgrade
These upgrades are permitted by our But now Now Actually exhibiting the crash is now easy, just set
and let |
We never pass on unknown values, but we can pass on values at “accidential types”. We said that at |
Right. Our opt rule relies on detecting any eventual inconsistencies and falling back to null. But if functions are involved, we do not detect any inconsistency. And when an actual call is performed, we have already forgot the assumed type, so a deferred check a la gradual typing isn't possible either. It seems like the only fix here would be to go the proper gradual typing route and wrap deserialised function values -- or equivalently, remember their deserialisation type and use that for calls as well as for consecutive serialisations. And yes, do a subtype check upon deserialisation if we want eager detection. |
I don’t think wrapping (i.e. eta-expanding and inserting code) works, as we can’t transfer closures, only the raw function or service reference, but
might help. But:
Is that really optional? If we don't do that check in the decoding of It seems that the eager subtype check is a reqiurement to fix this problem, and if we have the subtype check, we don't actually need any wrapping/type passing. Because the subtyping check is enough to ensure that
and then the generic soundness proof applies again. |
Well, if we were to go the gradual route then there wouldn't be any check at deserialisation time, but it would decompose into checks at use. Of course, it is too late to fall back to null at that point, so instead you'd get deferred type errors with some notion of blame (which arguably is of limited use in a distributed setting). That would be a possible semantics, but one can argue that it doesn't fit our approach. So yeah, the choice seems to be between
All else being equal, (1) would seem like the obvious choice. But the price would be implementing subtyping in deseralisers. |
I am warming up that idea… it’s not like it’s impossible. The main complexity I see here is to implement recursion breaker correctly, likely by memoization as we do in |
You cannot avoid the quadratic worst-case with structural subtyping. That's not necessarily a problem for normal programs, which won't ever exercise this, but might be a concern with malicious parties sending "type checking bombs" (though I believe even that is only possible if the receiver uses a non-minimal type definition itself). My other worry is that the extra complexity of implementing such a subtyping check might be seen as a con against using Candid. |
So what if we had a separate I'd much rather we ship something less expressive and sound than more expressive and unsound but I'm guess you are all with me on that, right? |
@crusso, interesting idea to restrict the offensive subtyping to first-order! I don't even think you'd need a separate type for that (which would be harder to target). Simply restrict all "inverse" and "unsafe" rules to only apply with first-order constituent types, which should cover the vast majority of use cases. @nomeata, what do you think? |
Another suggestion might be to make the |
This might be too restrictive. Recall that we want both of these:
And hence, by transitivity, we get the problematic So you are saying we don’t get In other words, you can add new optional arguments to your services, but not if they would take function or service references?
I don’t think that works; see the example above. You need variance (in fact the “anything goes rule”) to keep the relation transitive. |
Yes, such a restriction would prevent you from (contravariantly) adding higher-order fields, even when optional. I was thinking that that may be okay? Such a case might be rare enough that it is fine to require a more heavyweight solution for it. But obviously, I'm hand-waving with that reasoning. |
Such as using the untyped Still not easy, because of transitivity. It sounds we want
but unless we restrict the totally harmless rule
but now by transitivity we have
and it's broken again. |
Yes, good point. In a certain twisted way I find it reassuring that hacks like that won't work. :) |
In a way I find it reassuring that certain twisted hacks like that won't work! |
We could forbid function types under |
Or use a different opt type for this, which is what I was suggesting with |
Even without the Following the runtime check idea, we can fetch the current interface while deserialization, and we only check for subtyping for the first-order case? |
The promise of candid is that, as long as you only ever upgrade along
If we are willing to check for subtyping anyways, we can just apply that to compare the type in the type description with the expected type. That’d be good enough to solve the problem at hand. Fetching the actual interface at decoding doesn't sound appealing, decoding a message should not require async calls (in canisters) or network lookup (outside), I hope. |
Ok, so what options (heh) we have:
Anything else? |
I think that about sums it up.
Isn't that always the case? |
Right, but one might argue that adding “unlikely situations will cause decoding errors don’t actually occur” to the list of assumptions isn’t too bad. Not being able to prove theorems is probably worse :-) |
based on our experience, we really can really use some formal treatment of our Candid work. This branch contains some initial work. Part of this commit: * A simple Coq setup (using the more modern dune-based setup) * A nix-setup to build this * Simple CI integration * A Coq-ification of the definitions in IDL-Soundness.md * Mechanizing the “canonical subtyping is sound” proof in that document More work is pending on #141, with ongoing experiments in #147.
@rossberg, so what direction should we head here? Can we get around doing the subtyping check upon decoding? |
This contains an initial formalization of Candid in Coq. It covers these types: `nat`, `int`, `null`, `opt t`, `empty`, `reserved` It considers two different formalisms: * `NoOpportunisticDecoding` This is without `t <: opt t'`, but with `t <: opt t`. Things go through, although the `opt t <: opt t'` rule has to be tweaked to keep subtyping transitive (see #146). * `OpportunisticDecoding` This is with `t <: opt t'`. Because of the negative hypotheses in the coercion relation, this can’t be defined as a simple inductive relation (which is a seroius smell!). So instead I define it as a function (which is mostly straight forward), and prove the properties there. We _could_ take this as indication that maybe our spec should also just define it via equations. We don’t gain anything from the relational presentation, I think, and implementors all anyways implement functions. Nevertheless, I did prove that the relation defined by admits the intro and induction rules that _would_ come out of the inductive relation (ignoring the negative hypotheses). This revealed some issues with the way the rules are presented, will create a PR for that soon. In both cases, I prove * Correctness of decoding * Roundtripping * Uniqueness of decoding * Soundness of subtyping * Transitivity of subtyping I do _not_ prove Higher-order soundness, because we know it does not hold (see #141). This also uses some proof-of-concept “named Coq cases“ feature, see https://www.joachim-breitner.de/blog/777-Named_goals_in_Coq.
It seems like you warmed up to that solution... I'm still on the fence with the extra complication, but I'm willing to try. Would this actually be conservative over the status quo? I.e., if we later decide to drop the check again, it does not break anything that works in the presence of the check? |
Well, dropping the check will break the issue that we are trying to fix by introducing it… The effect of introducing the check is to turn a decode failure later at some other canister into a decode failure earlier. And this earlier failure can be safely caught via the I am not really warmed up. It’s shooting with a cannon at a relatively obscure corner cases. But I don’t see an alternative at this point, short of just giving up on any formal notion of soundness. But then, why even bother? |
Yes, that's what I'm getting at: removing the check would only delay or eliminate failures. It would not change the behaviour of non-failing executions. At least I assume that is the case. That means that we can safely introduce it and have the emergency option of falling back to the status quo later if it turns to not be practical (or nobody implements it). Certainly not what I'd want, but at least avoiding a sort of dead end.
Yes, agreed. The only sound alternative I see would be proper gradual typing, but then every language runtime would have to push that through for function and actor references, which seems way more unrealistic. |
It would! Because decoding under |
Ah, you're right. Oh well. Maybe it's just not worth over-theorising possible futures at this point... |
Probably not, but what does that mean? Introduce the subtyping check, or leave things as they are? |
Sorry, means going forward with introducing. If there are no objections. |
No, just faint hopes that maybe you’ll have a great idea about how we get around doing that. It is going to be a PITA to implement that… But it means we do the subtyping check on the Right now, the value If we do subtyping checks, we do them not just when decoding reference types, but rather when decoding an |
I wish...
Interesting question. That was what I was assuming, but I suppose we could only do the check on reference types. That would imply that the first-order fragment of the IDL does still not need such checks. Maybe that's an attractive property? But of course the soundness property becomes more wishy-washy then. To be honest, I'm a bit fuzzy on the implications right now. Have you thought that alternative through? |
Not completely through, but I think doing the check on
Although, maybe this change will void us from having to worry about independent implementors… |
Yeah, avoiding the backtracking certainly is big plus, both semantically and implementation-wise. As for implementors, maybe we should offer a generic implementation in C, that other languages/runtimes can use? |
Let’s refine the precise formalism in #168
I guess that might be something to think about. Other languages would then probably have to turn their “expected” type into something the generic implementation can work with, but it could actually be just a type description as we use in the message format (generating that is probably needed anyways). I was hoping that you could pre-process given and expected type and easily mark all the |
Maybe a function that takes two type tables (as they occur in the messages), and return a list of “type table paths”, pointing to all nodes in the output type’s graph (well, tree) where an Ah, or maybe better: a set of pairs (input type table index, output type table index), where That should give a decoder (like ours) sufficient information, while containing enough the hard work. Still ugly. |
Looks like we still haven’t found the grail yet…
TL;DR: Our relaxed coercion rules for opt (#110 and following) may lead to “hard” deserialization errors later on.
I seems that the following can’t all hold:
Counter-example
Here is the counter example:
If we pass
A1
andB1
toC.init
, all is well.C.run
works fine.Now we upgrade
A1
toA2
andB1
toB2
, as follows:These upgrades are permitted by our
:<
, and actually desirable (i.e. restricting<:
to avoid “obviously bogus” upgrades doesn’t help)But now
C.run
will cause B2 to passopt wantsInt
toA2
. This will succeed at deserialization (because “Function and services references coerce unconditionally”). Now A2 invokeswantsInt.foo(true)
, and the deserialization of(true)
at expected type(bool)
will fail inwantsInt
.Generic Soundness proof
In https://github.com/dfinity/candid/blob/master/spec/IDL-Soundness.md#proof-for-canonical-subtyping we have a generic soundness proof for “Canonical subtyping”, that says “A solution with canonical subtyping (transitive, contravariant) is sound.”. And our
<:
is that!But it’s not “decompositional” any more. In particular,
(as required in the generic proof) doesn’t hold .
Concretely in our example above, we have
opt WantsInt <: opt WantsBool
(because allopt
types related). But because the reference value is passed through, this also meansWantsInt in opt WantsInt <: WantsInt <: opt WantsBool
, but we don’t haveWantsInt <: WantsBool
.So…
Dunno. No concrete suggestions. It’s sunday morning, and I just wanted to get this insight out of my head.
The text was updated successfully, but these errors were encountered: