hapijs / hapi

The Simple, Secure Framework Developers Trust

Home Page:https://hapi.dev

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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:

hapi/test/server.js

Lines 1006 to 1015 in 2f80e84

it('returns promise on empty ext handler', async () => {
const server = Hapi.server();
const ext = server.ext('onRequest');
server.route({ path: '/', method: 'GET', handler: () => 'ok' });
const res = await server.inject('/');
expect(res.result).to.equal('ok');
const request = await ext;
expect(request.response.source).to.equal('ok');
});

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:

hapi/lib/server.js

Lines 251 to 275 in 2f80e84

ext(events, method, options) { // (event, method, options) -OR- (events)
let promise;
if (typeof events === 'string') {
if (!method) {
const team = new Teamwork.Team();
method = (request, h) => {
team.attend(request);
return h.continue;
};
promise = team.work;
}
events = { type: events, method, options };
}
events = Config.apply('exts', events);
for (const event of events) {
this._ext(event);
}
return promise;
}

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...
}

I believe this was resolved by #4345.