Skip to content
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

fix(server): address Node v10.x req close event firing order #1672

Merged
merged 15 commits into from Jun 7, 2018

Conversation

hekike
Copy link
Member

@hekike hekike commented Jun 5, 2018

Req close always fires in Node 10 and fires in a process.nextTick
This PR handles the close event in restify in a way that we only return an error if the response is not flushed yet.

Related changes in Node:

@hekike hekike changed the title WIP: fix(server): address Node v10.x req close event firing order fix(server): address Node v10.x req close event firing order Jun 6, 2018
lib/server.js Outdated
req._connectionState = 'close';

// Do no handle close if request is already flushed
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo "Do not"?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, is it "if response is already flushed" here, or do we mean "request" in the sense of "the whole request/response"? I think it'd help to distinguish between request and response here for clarity, and in this case it seems we're checking if the response is flushed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch!

lib/server.js Outdated
req._connectionState = 'close';

// Do no handle close if request is already flushed
// In Node >= 10 res's close event is always emitted
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is is Node > 10.4.0 at least? It seems that change hasn't been made it to any release yet.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fix seems to be working with all supported Node.js versions, 6,8 10.0.0 and 10.4.0 as well.
I made the comment more accurate.

lib/server.js Outdated
// Handle res's close on to the next cycle
// Required in Node >= 10:
// introduced in: https://github.com/nodejs/node/pull/20941
req.once('close', function onReqClose() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it that because res' 'close' event can now be emitted using process.nextTick we want to handle req's 'close' event on the actual next tick of the libuv event loop so that we process req's 'close' event after res's 'close' event?

It seems this ordering could break if .e.g node started emitting the res' 'close' event using setImmediate, which seems a bit brittle.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, what would you recommend to make it more robust?

res.send('hello world');
setTimeout(function() {
next();
}, 500);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need any setTimeout call here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We generate some "work" in the server to be sure that events are firing in the right order. In my previous fix tests passed with positive false.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by "some work"? It seems it's not CPU-bound "work", so is it a way to make events on request/response objects to happen in a specific order or within a certain timeframe? If so, should we document what the test is actually testing?

EDIT: I get it now: we want to send the response and call the next callback on separate turns of the event loop to exercise a specific edge case. Following question + whether we should document what we're doing with this test still relevant:

Why not keeping the original test? Should be add a new test instead of replacing the existing one?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remaining questions above still relevant:

  1. Should we document why we want to control the timing of those operations in this test?
  2. Should we also keep the original test and add a new test with those changes?

assert.equal(metrics.connectionState, undefined);
// close in Node >= 10
assert.include(
[undefined, 'close'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does that '[undefined, 'close'] mean?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any of them. Undefined is the unknown connection state basically.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the cases when the connection state would be 'close'? Shouldn't it be 'close' only when the request was aborted? In this case it doesn't seem like the request would be aborted.

lib/server.js Outdated
res.once('error', onError);

// Request events
req.once('close', onReqClose);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new logic seems a lot more robust than the original changes 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's also ironic that the Node.js documentation states that after the 'finish' event, no more events will be emitted on the response object, when it's clearly not true after the change at nodejs/node#20611.

@misterdjules
Copy link
Contributor

misterdjules commented Jun 7, 2018

In general it seems that we should not consider that a 'close' event emitted on a request object on the server side means that the request was prematurely closed.

For instance, such an event will always be emitted when the server finishes writing on the response stream, regardless of the state of the request.

That 'close' event will also be emitted when a request aborts, which I think is the use case we're trying to handle. In that case though, the 'aborted' event is always emitted.

So instead of using the 'close' event, it seems that we should only care about the 'aborted' event on request objects on the server side. The 'aborted' event is only emitted in case the request is prematurely closed.

It might allow to simplify the current changes even further.

@hekike
Copy link
Member Author

hekike commented Jun 7, 2018

Excellent idea, implemented!

lib/server.js Outdated
res._flushed = true;
req._timeFlushed = process.hrtime();
// Finish may alread set flushed timer
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: typo "already".

lib/server.js Outdated
@@ -1091,7 +1091,8 @@ Server.prototype._onHandlerError = function _onHandlerError(err, req, res) {
return;
}

res._handlersFinished = true;
// Handlers doesn't continue when error happen
self._onHandlerStop(req, res);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the implications of calling _onHandlerStop instead of "just" setting _handlersFinished = true?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reverted

lib/server.js Outdated
self._finishReqResCycle(req, res);
}
function onError(err) {

// Emitted when the response has been sent.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still true? Also, I think I know the answer, but is it possible for close to get fired but _flushed is false?

lib/server.js Outdated
req.removeListener('close', onClose);
res.removeListener('error', onError);

// Do not trigger false
self._finishReqResCycle(req, res);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to call _finishReqResCycle here?

lib/server.js Outdated
req.removeListener('close', onClose);
res.removeListener('finish', onFinish);
self._finishReqResCycle(req, res, err);
self._finishReqResCycle(req, res, res.err);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to call _finishReqResCycle here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To tigger after (after is triggered after both handlers are finished and req is flushed)

@@ -103,6 +106,7 @@ describe('request metrics plugin', function() {
it('should return metrics with pre error', function(done) {
SERVER.on('uncaughtException', function(req, res, route, err) {
assert.ok(err);
res.send(err);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any exception thrown in this test? It doesn't seem so, it seems there's only an error being passed to a handler's next callback? If so, why does the server emit 'uncaughtException'?

Also, why do we need to res.send() now when we wouldn't need to do it before?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

f you listen to this event, you must send a response to the client. This behavior is different from the standard error events. If you do not listen to this event, restify’s default behavior is to call res.send() with the error that was thrown. - docs

So it should have been there

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in this test before your changes we would call done() because the 'after' event would be emitted no matter what, but we wouldn't execute the assertions in the client's request's callback?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

@@ -139,6 +143,7 @@ describe('request metrics plugin', function() {
it('should return metrics with use error', function(done) {
SERVER.on('uncaughtException', function(req, res, route, err) {
assert.ok(err);
res.send(err);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question regarding 'uncaughtException' and the need for res.send() than above.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment above

assert.equal(metrics.connectionState, undefined);
// close in Node >= 10
assert.include(
[undefined, 'close'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there two possible values here? Shouldn't we always expect 'close'?

@@ -260,6 +271,7 @@ describe('request metrics plugin', function() {
{
server: SERVER
},
// TODO: test timeouts if any of the following asserts fails
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we file a corresponding issue?

@misterdjules
Copy link
Contributor

@hekike Added a bunch more comments, but I think this is going in the right direction, thank you very much again for considering my feedback!

assert.equal(metrics.connectionState, undefined);
// close doesn't always fire in Node < 10
assert.include(
[undefined, 'close'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem with using assert.include here is that if we run this test with Node.js >= 10.x and we expect the connection state to be 'close', the test would pass even if the connection state was undefined. Would it be too much work to differentiate between node >= 10.x and < 10.x here and have two different asserts, something like:

if (process.version >= 10.0) {
  assert.equal(metrics.connectionState, 'close');
} else {
  assert.equal(metrics.connectionState, undefined);
}

?

Same for all other connection state assertions in this file.

@@ -63,6 +63,7 @@ describe('request metrics plugin', function() {
assert.isAtLeast(metrics.latency, 150);
assert.isAtLeast(metrics.totalLatency, 150);
assert.equal(metrics.path, '/foo');
// close doesn't always fire in Node < 10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that comment still relevant?

@@ -271,6 +281,7 @@ describe('request metrics plugin', function() {
assert.isNumber(metrics.latency, 200);
assert.equal(metrics.path, '/foo');
assert.equal(metrics.method, 'GET');
// close doesn't always fire in Node < 10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that comment still relevant?

lib/server.js Outdated
// Set status code and _sent for audit
// as req is already closed connection
res.statusCode = err.statusCode;
res._sent = true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to set _sent to true?

Copy link
Contributor

@misterdjules misterdjules left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beautiful! Thanks for being persistent and patient with me!

@hekike
Copy link
Member Author

hekike commented Jun 7, 2018

Thanks for re-reviewing, restify just get better ;)

@hekike hekike merged commit 6be3fb7 into master Jun 7, 2018
@hekike hekike deleted the fix/node-10 branch June 7, 2018 20:39
@hekike hekike mentioned this pull request Jun 10, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants