Memory leak when passing undefined lifecycle handler
opened this issue · comments
Support plan
- is this issue currently blocking your project? (yes/no): No
- is this issue affecting a production system? (yes/no): Yes
Context
- node version: v16.13.2
- module version with issue: "@hapi/hapi": "^20.2.1"
- last module version without issue: ??
- environment (e.g. node, browser, native): node
- used with (e.g. hapi application, another framework, standalone, ...): standalone
- any other relevant information: -
What are you trying to achieve or the steps to reproduce?
Hi, I've found a way to trigger a memory leak, by passing an undefined value to a lifecycle hook such as onPreAuth
. Hapi does not trigger any validation for server.ext('onPreAuth', UNDEFINED_FN)
.
Passing an undefined value causes hapi to hold on to the request and response objects.
See my example here: https://github.com/RichPMad/hapi-memory-leak-undefined-lifecycle-fn
What was the result you got?
Request
and Response
objects are not cleared from memory. No warnings are given, and no validation errors happen when setting up lifecycle hook.
What result did you expect?
Lifecycle hooks warn me I'm being dumb and accidentally passing an undefined value.
Hey! I understand the confusion here, but I think you're running into intentional behavior of the framework. When you don't pass a method to server.ext()
, it returns a promise that will resolve with the request
the next time the extension is invoked. It's possible that letting these promises hang out could be the cause of a memory leak, though this API is primarily to help make testing more convenient.
Here's the section of the docs: https://hapi.dev/api/#-serverextevent-method-options
The method may be omitted (if options isn't present) or passed null which will cause the function to return a promise. The promise is resolved with the request object on the first invocation of the extension point. This is primarily used for writing tests without having to write custom handlers just to handle a single event.
Here's a backing test for the behavior:
Lines 1006 to 1015 in 2f80e84
Sorry for any confusion that may have caused! If you want to ensure you never run into this, you can use server.ext({ event, method })
instead of server.ext(event, method)
👍
Thanks for the quick response!
Oh wow, I see, sorry I glossed over the docs on this one.
That's pretty tricky 😄 This might be the first time I didn't expect some behavior from hapi. I will certainly use the object syntax instead, thanks for the support.
Oh, there is a memory leak here!
The server.ext()
logic will create a new method using teamwork to return a promise:
Lines 251 to 275 in 2f80e84
This new method unconditionally records the request
object in team.attend(request)
. Teamwork then unconditionally stores this note, even after it is "done". This is never cleared, thus we have a leak:
https://github.com/hapijs/teamwork/blob/0e7b0f13a415d3c820f417107a20568ab2f6709d/lib/index.js#L35-L54
I would say that teamwork storing the "note" after it has resolved is a big no-no, and should be fixed in teamwork. Additionally, teamwork should clear any stored notes when resolving or rejecting. Otherwise they will be tied to the life-time of the teamwork
object (which in this case is infinite).
The above will fix the major leak of each request
, and a minor leak of the first request
.
FYI, it is complicated to distinguish passing undefined
, vs. omitting the argument (unless you use the controversial arguments.length
property).
It would have to look something like this:
ext(events, ...args) {
const [method, options] = args;
Hoek.assert(args.length === 0 || typeof method === 'function', 'Method must be a function');
// regular logic...
}
But that breaks the current API, which allows it to be null
/undefined
while still passing options
and returning a promise.
Given your case, it might be worth to change the API to always throw an error when a function
is not passed. If we made the method
argument optional when passing options
, it could work since we can detect if method
is really an options
object:
ext(events, ...args) {
let [method, options] = args;
if (typeof method === 'object') {
options = method;
method = null;
}
else {
Hoek.assert(args.length === 0 || typeof method === 'function', 'Method must be a function');
}
// regular logic...
}