proposal-explicit-resource-management: accessing a disposed binding (through a closure) could be an error
Consider
{
using x = foo();
setTimeout(() => consume(x), 10);
}
If I understand correctly, this will access a disposed resource.
Could we make accessing a disposed binding a TDZ-style error instead? Any reason this should be legal?
(You can accomplish this sort of leak in other ways, of course, but leaking a closure which captures a binding is a particularly easy mistake to make.)
About this issue
- Original URL
- State: closed
- Created 2 years ago
- Comments: 27 (4 by maintainers)
My interpretation is “we shouldn’t add an extra guardrail just in case someone forgets to implement their own guardrail”. There is plenty of code that already exists that needs to defend itself just as thoroughly when using
constorlet, and that doesn’t go away even whenusingis available because anyone can still just useconstorlet.usingdoesn’t make the kind of guarantee about that object’s lifetime that introducing a TDZ seems to make.usingdoesn’t free the object, it informs the object to free its resources. The fact that your workaround to alias ausingbinding via a secondconstbinding could even work is evidence that a “free the object” guarantee isn’t made, otherwise I’d expect even theconstbindings would become invalid (i.e., the object reference itself becomes unreachable). Without that guarantee, this extra guardrail seems like overkill.It’s important to note that
[Symbol.dispose]()isn’t a finalizer, as its perfectly reasonable for a disposable object to be reusable. You can have[Symbol.dispose]()emulate a finalizer, but that is explicitly not a requirement. Introducing a new TDZ doesn’t even prevent use-after-free, as evidenced by your workaround. It only potentially reduces it while making it harder to reason over whether ausingbinding in a callback might still be reachable. Type systems wouldn’t even be able to help here because there’s no way to know whether a callback might be invoked immediately or later, so all bets are off when it comes to control flow analysis. This is currently the case withlet/constbindings used in a callback in TypeScript since we can’t guarantee that a callback would be invoked before the bindings are initialized. It seems like this would make it harder to useusingin general, rather than make it easier to avoid use-after-free.I too am wary of adding a new TDZ behavior. On the other hand, I am also wary of adding a whole new class of use-after-free bugs, and the latter concern weighs more heavily on me.
@mhofman My suggestion is that accessing it would be an error, not
undefined.I remain against adding a TDZ. I both disagree with the language design choice of being paternalistic here, and I dislike the implementation complexity (not necessarily difficulty) that additional TDZ behavior introduces.
More details and recapping of my thoughts from Matrix discussions below.
@@disposeis called, so the main value add of re-entering TDZ is to throw a bit earlier in the evaluation. I find that paternalistic for not much gain in practice.@@disposecalled on it is even necessarily bad. The current design is more flexible and allows you to have reusable objects. Yes, a workaround exists, but it’s kind of gross and since I disagree with the motivation that this is badness that ought to be prevented, I do not want to make this proposal less flexible to begin with.usingbindings will end up with more TDZ check-and-throw cost than existing lexical bindings. This is undesirable for performance, both runtime and bytecode or JIT code size. For the same reasons that linters need to be very sophisticated to correctly lint misuses here, so will implementations need to be very sophisticated to optimize these away. I do not want to penalize context loads ofusingbindings. Specifically I want load costs ofusingbindings to be the same as other lexical bindings.@rbuckton – above you asked:
XS doesn’t have any provision for the value of
constto change after being initialized. Sorting out how to make that work and updating the closure doesn’t feel trivial.But, perhaps more importantly, both @patrick-soquet and I agree with overall point of @syg and you, that adding a new TDZ is significantly more complex, both in terms of engine implementation and required developer understanding, than is justified to help a little in one case. There are already so many other ways for a developer to use the disposed object outside the
usingblock already, why address just this one? Host object implementations built on XS already take steps to defend on use-after-close/free/@@dispose and XS provides APIs that help hosts do this reliably.You need to understand that either way, because closures which run after disposal will see the resource in a different state than closures which run before it. At least with TDZ you’re guaranteed to get an error instead of just observing the thing in a state you weren’t prepared for. This is a better experience in the typical case.
If you think people aren’t always going to be paying attention to whether closures are running before or after disposal, I agree, and that’s a very strong argument for having TDZ.
It’s true that there are some cases where you explicitly want the object to outlive its disposing. But it’s trivial to accomplish this by introducing a new binding, in those unusual cases. On the other hand, we have years of experience telling us that people don’t easily notice errors like the above, and I think guarding against them would be worthwhile. It’s true that sometimes they’ll get a different error from trying to use the resource after it has been disposed, but not all resources will provide a helpful error for those cases, and the language can help.
C# and Java are not particularly good analogies because it is much rarer to create closures in those languages.
The whole point of the RAII style is that the binding is live for the life of the block, and then the binding is discarded and the resource disposed at the same time. If the binding outlives the block it’s not coherent.
I think this is the key point. Whether or not we have a TDZ, and whether or not it is desirable to preserve and reuse the binding in some use cases, it is always trivially possible to preserve the object that is being disposed by storing a reference to it elsewhere. Operating on the disposed, non-reusable object should throw, but this needs to be implemented by the author of the disposable object. Having a TDZ might give them the false security of “oh, I don’t need to remember that I’m disposed, since the binding gets uninitialised anyway”, which would lead to bugs.
I’m a bit wary about introducing a new TDZ behavior, but will consider it if it becomes necessary for advancement.