-
Notifications
You must be signed in to change notification settings - Fork 136
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
Add option to detect and error on dependency lifetime mismatch #349
Add option to detect and error on dependency lifetime mismatch #349
Conversation
Adds new container option `errorOnShorterLivedDependencies` which enables detection and errors on cases where a longer-lived module resolves a shorter-lived dependency, which is then preserved inside the longer-lived module even after its own lifetime and cached resolution expires. Fixes #348
Confirms `errorOnShorterLivedDependencies` compatibility with per-module local injections.
@jeffijoe I just realized this option causes a new problem. By default all values registered with I propose making values have scoped lifetime by default, and allowing |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fantastic work @fnimick !
Left a few comments but I definitely want to merge this feature. I have thought about this a few times but I decided against it mainly because Microsoft's container in C# behaves the same way, but having an option to warn against this rather than either enforcing or allowing it is even better.
I do think we want to make this a major release though since defining your own resolvers is not uncommon, and unless they specify a lifetime they will likely trigger this error.
Speaking of, perhaps we should have an example of triggering this error in the README, including the error message, to help people who may encounter this error with searchability.
@jeffijoe re: "I do think we want to make this a major release though since defining your own resolvers is not uncommon, and unless they specify a lifetime they will likely trigger this error." That's why I left this option as defaulting to "false", so that any current code that would trigger this error will continue to run as normal until the option is manually specified. That was my thought to avoid a breaking change necessitating a version bump. Since it's very likely to be an error if this behavior is occurring, should we default this option to true, then do a major version bump instead? |
Good point; the resolution stack is an internal implementation detail so changing its' structure should be ok. I'm ok doing this as a minor version. |
@jeffijoe it's an approach question, not a "correctness" question per se. Do we want to have this be a default behavior such that people won't have to dig into the docs to realize they need to turn this option on for increased safety? (at the very least, with the next breaking change, it should be made the default, I think) |
Yup I would agree with that. We'll make it opt-in for now in a minor version, then potentially make it opt-out in the next major, or maybe even eliminate the option depending on feedback (if any, lol). |
@jeffijoe developer UX question for you. There's a potentially unergonomic case that I discovered when modifying examples to use the new option - if you register a singleton service on the root container, and also a value, it errors since the value is registered as scoped. You need to manually register the value as a singleton in this case. My suggestion is to treat values as singletons by default if and only if they are registered directly with the root container. I'm not entirely sure how to accomplish this yet - but would you support this? That way values registered on the root container don't need to be explicitly marked as singletons in order to be depended upon by singleton services. There's a few separate questions that I have regarding how to approach the mixing of singleton and other lifetimes:
Should we make it explicit that singletons should only ever be registered one the root container, and never on scopes? EDIT: @jeffijoe if we added an optional restriction for singletons registered only on the root, would you support renaming this option from |
Also update all examples to use it (and fix examples where necessary)
These are all good questions. We could have an option on the resolver {
isLeakSafe?: boolean // name pending, suggestions?
} And then any resolver that marks itself as I wonder if things are becoming too complicated. Maybe this is why Microsoft's DI container doesn't do this. 😅 Thoughts? |
I'd say we should wait for someone to come with a compelling use case for a resolver to deliberately want to capture and leak information with a shorter lifetime before we allow the ability to disable it on a resolver-by-resolver basis. If you intend for a value to last as long as a singleton, just... register it as one. I'd still like to align on answers to the other two questions - should it be illegal to register two singletons with the same name on different scopes from the same root container? (or in the root container in a scope?) to implement this I would have to add a check at register for the lifetime and to hoist all singletons to the root scope and register there, so I can detect the collision at registration time rather than at runtime where the source registration for the resolved cached module is no longer available for comparison (to detect if we are getting a singleton from a different registration than the one locally available) |
I would say it should be illegal to register a singleton on a non-root container. Regarding the resolver option, my idea was that asValue could utilize it so we don’t need to special case it. |
Ah, it wasn't clear (and still isn't, from the option name and the docs) whether this option would mean that: say A singleton depends on B transient. Would the I assume you mean B, but it's not clear from the naming that that would be the case, and I think removing the need to have this be managed at all by the user would be an advantage. |
Update `asValue()` to automatically set lifetime based upon registration container (root vs scope) Add checks to throw an error when a singleton is registered on a scope in strict mode
@jeffijoe added strict mode + docs: enables checks for lifetime mismatch, and disallows registration of singletons on scope containers. I also added intelligent value handling, so developers won't have to specify values as singletons when registering them on the root scope in order to have them be available to singleton modules. It detects whether the target container is a root container or a scope and sets the appropriate lifetime accordingly. Note: I added "9.1" as the introductory version for strict mode in the README, assuming that's the version number we would use for this release. |
src/container.ts
Outdated
|
||
// If this is a value resolver, set the lifetime to `Lifetime.SCOPED` if this is a scoped | ||
// container, or `Lifetime.SINGLETON` if this is the root container. | ||
if (resolver.isValue) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is what I meant when I was saying I would like to not special case asValue
. Also, the aliasTo
isn't covered, right? I should be able to register aliasTo()
without specifying a lifetime (default being transient) since it'll essentially just "redirect" to the given registration.
My idea with the isLeakSafe
(again, can workshop the name) was that we wouldn't need to tamper with the lifetime, but rather bypass the check that enforces the case where A depends on B, but A is singleton and B is scoped or transient (B would be leaking, since it lives longer than it was supposed to). However, if B is just an aliasTo
, then it is safe to leak since when resolving the alias, the underlying registration will itself be subjected to the same checks. Or if it's asValue
, then it's safe to leak because the value itself is already long-lived.
Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's an extremely good point. I was worried about aliasTo
because really it needs to inherit the lifetime value of the registration it resolves to - but this is okay because we carry forward the check in the resolution stack. I was worried that there would be a mismatch between the lifetime defined at resolver creation time and the effective resolution which is undefined because aliasTo can resolve to a registration defined in a lower scope, and that therefore I couldn't give aliasTo
a meaningful lifetime - but as you point out, we don't actually need to, since it's always a transient that resolves to a registration that has its lifetime checked.
However, I still think there is a problem where A is a singleton and B is a value defined at a lower scope. In this case, we want to detect this lifetime error. The analogous case where A is a singleton and B is a value defined at the same root scope is not an error. That's why values need special behavior that takes into account the container level they are registered at - it's not sufficient to always exclude values from leak checking, the way we should always exclude aliases from leak checking.
@jeffijoe my conclusions from the above conversations:
Look at the koa example for a perfect illustration of the latter. https://github.com/jeffijoe/awilix/blob/master/examples/koa/index.js#L25 |
- Add `isLeakSafe` to resolver to support excluding alias registrations from lifetime leak checking (since their targets will be checked) - Add tests for alias registration lifetime leak checking - Add `ResolverInternal` interface to include optional `isLeakSafe` and `isValue` params, which should not be exposed in the public library interface
src/types.ts
Outdated
@@ -0,0 +1,16 @@ | |||
import { Resolver } from './resolvers' | |||
|
|||
export interface ResolverInternal<T> extends Resolver<T> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding these to Resolver
is ok, since each new property is optional; we don't need a new type. Besides, this "internal" type is leaking to the public since it's exposed in the public container interface. 😅
src/types.ts
Outdated
/** | ||
* True if this resolver should be excluded from lifetime leak checking. Used to exclude alias | ||
* resolvers since any lifetime issues will be caught in the resolution of the alias target. | ||
*/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's make this a bit more generic as well. It's perfectly fine for custom resolvers to use this. For example, I have an asLazy
(not in core because I don't recommend it) that resolves when any method is called on the returned proxy.
/** | |
* True if this resolver should be excluded from lifetime leak checking. Used to exclude alias | |
* resolvers since any lifetime issues will be caught in the resolution of the alias target. | |
*/ | |
/** | |
* True if this resolver should be excluded from lifetime leak checking. Used by resolvers | |
* that wish to uphold the anti-leakage contract themselves. | |
*/ |
src/types.ts
Outdated
/** | ||
* True if this resolver is a value resolver. Used to implicit set the lifetime of a value | ||
* resolver to either {@link Lifetime.SCOPED} or {@link Lifetime.SINGLETON} depending on whether | ||
* this value is registered to a root or a scope container. | ||
*/ | ||
isValue?: boolean |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should make this a bit more generic, maybe autoLifetime
? I'm trying to avoid coupling these new options to any particular resolver type.
Maybe as an alternative, we could use the root container to resolve any requested SINGLETON
, that way any overrides would not be used by singletons, but could be used by scoped. Do you think that would have any negative side effects?
If we do this, then we can make asValue
return lifetime: SCOPED, isLeakSafe: true
and we'd only need the isLeakSafe
option.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I considered that but changing the resolution behavior would be a breaking change outside of strict mode - unless you're suggesting that strict mode should change the resolution behavior?
If we were to do that, we could avoid leak checking for singletons entirely, since they will always resolve on the root container with only root container elements. Even a scoped registration set on the root container would be safe for a singleton to depend on because it will only ever pull that registration from the root container, and not from a potential scope.
The question is:
const container = createContainer({ strict: true })
container.register({ s: asFunction(cradle => cradle.value).singleton(), value: asValue('foo') })
const scope = container.createScope()
scope.register({ value: asValue('bar') })
if you call scope.resolve('s)
, do you expect it in this case to return foo
by resolving the scoped value from the root, or do you expect a lifetime leakage error since technically it's resolving the scoped value?
EDIT: To extend this hypothetical, would you expect a singleton resolving a scoped non-value module in the root scope to resolve successfully (though the module may fail to find its dependencies that are only registered in scopes) or would you expect it to error on a lifetime issue?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually @jeffijoe I just had an idea that sidesteps this problem entirely. For the purposes of lifetime checking, why not treat a scoped lifetime, defined at the root level, as equivalent to singleton if and only if the resolver used during resolution is that root-registered scoped module (rather than one defined in a scope)?
The only potential gotcha is that the error message for a "real" lifetime issue will be pushed down one level in the resolution stack. e.g. imagine A (singleton) depends on B (scoped, registered at root) depends on C (scoped, registered in scope). In the current setup, A -> B will trigger the error, but in this proposed change, it is only on the resolution of C that this problem is detected.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Regarding the first question, I would expect the result to be foo
because the singleton would be using the registration from the root container; this seems like the most natural to me, as it means it doesn't matter whether it was resolved via a scope or the root, it would always be deterministic.
Now that you mention the case of a singleton depending on a scoped value, I currently don't see a legitimate reason for that ever happening with the exception of the leak-safe resolvers. Perhaps what we should do is have strict
mode change the resolution behavior to the one we discussed in addition to enforcing these checks. I believe a singleton depending on non-singletons is the big issue we are trying to prevent with this change overall. By doing so, we'd get the (IMO) most natural behavior for asValue
.
If this is the way we go with, then I would like to make strict
the default in the next major; I would even want to remove the option entirely, as I think this is the behavior that everyone should be using.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense. I'll update this to the new behavior for strict and push.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jeffijoe it'll be a bit. Changing the target container mid-resolution is a problem - i.e. if you resolve a scoped module, which has a dependency on a singleton, we want to switch resolution to the singleton for strict mode. Currently the assumption is that the container is always the same, and therefore closing over the resolution stack is sufficient since all resolve
calls from dependencies will target the same container.
As soon as the container is switched due to the singleton in the resolution path, the resolution stack is effectively cleared, meaning that we go one further round before a cyclic dependency is detected and the resolution path indicating a possible error is truncated.
I am guessing you don't want to change the API to pass the resolution stack through the calls from containers to resolvers back to containers?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah the resolution stack is an implementation detail so shouldn't be exposed. Hmm..
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jeffijoe done - I added a new internal container construction function to allow for sharing the same mutable resolution stack array between parents and scopes.
Should we remove the parentContainer
argument from the public container constructor as well? Technically it's part of the existing public API but it was not documented and should not have been used. If people use that, it will again decouple the resolution stack between parent and child containers and lead to problems in strict mode.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work @fnimick ! 🙏
I agree, we should remove parentContainer
. It was an implementation detail, I don't know why I didn't think of your solution earlier, I've used it plenty of times elsewhere. 😅
Remove `asValue` lifetime support, use `isLeakSafe` instead.
src/types.ts
Outdated
@@ -0,0 +1,6 @@ | |||
import { LifetimeType } from './lifetime' | |||
|
|||
export type ResolutionStack = Array<{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could probably put this type definition inside of container.ts
, it feels a bit alone here. Alternatively, we could refactor the type definitions inside of container.ts
to here, assuming we export them in index.ts
. What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That makes sense. I only did this as a quick hack to remove ResolutionStack
from the public API surface while avoiding having to manually specify all exports from ./container.ts
(since the index has export *
).
The proper solution is to go through and rather than using wildcard export, explicitly export only those items which are intended to be in the public API.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would this type need to be exported if put in container.ts
though?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope, I just went through and cleaned up all the wildcard exports to match what (I think) the public API should be.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Took a peek and I noticed RegistrationHash
is a public interface but isn't exported. Could there be others?
@jeffijoe now that this is a substantial change to the (undocumented) public API surface, do we want to keep it at 9.1 or move to 10.0? |
I would say the safest is to do a major to avoid any surprises. |
I'll update the docs to reference strict mode added in 10.0 - do we want to make it the default option? |
I think we should start with opt-in. As I mentioned before, I'd eventually want to not even have the option anymore, as I think strict mode should be used by everyone, but I'd like to get this into people's hands and get feedback first before making that move. |
@fnimick would you also like to do the honors of writing the changelog entry for v10? Traditionally I have always done it myself but this is a major contribution where having a paragraph or two about "this is what is changing, this is the motivation" would probably be written a lot better by the author than myself. 😄 I'm of course happy to do it, you've definitely done enough already! 🙏 |
@jeffijoe is there anything else outstanding we need to do in order to merge and release? |
@fnimick sorry I didn't get notified about your latest commit, I'll merge this and release ASAP. |
Adds new container option
errorOnShorterLivedDependencies
which enables detection and errors on cases where a longer-lived module resolves a shorter-lived dependency, which is then preserved inside the longer-lived module even after its own lifetime and cached resolution expires.Fixes #348
NOTE: this changes the interface of
ResolutionStack
which was technically exported but should never be needed by client code. Does this constitute a breaking change for versioning purposes?