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

Prevent server hangs on unconsumed streams. #81

Merged
merged 68 commits into from
Jul 12, 2018
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
97874e2
prevent app crash on error event
mike-marcacci Jun 6, 2018
f1d0553
fix lint issues
mike-marcacci Jun 6, 2018
d4ef915
more fixes for linter
mike-marcacci Jun 6, 2018
e5090ac
Merge branch 'master' into fix/prevent-crashes
jaydenseric Jun 7, 2018
8928ce0
Undo package script changes.
jaydenseric Jun 7, 2018
2cbbf2d
Minor tidy.
jaydenseric Jun 7, 2018
493a617
prevent app crash on error event
mike-marcacci Jun 6, 2018
148cfde
fix lint issues
mike-marcacci Jun 6, 2018
0ec1085
more fixes for linter
mike-marcacci Jun 6, 2018
3c5ce98
add a noop handler for parser error
mike-marcacci Jun 7, 2018
65f78f6
koa: finish request before responding
mike-marcacci Jun 6, 2018
9094cf9
fix lint issues
mike-marcacci Jun 6, 2018
ab36717
more fixes for linter
mike-marcacci Jun 6, 2018
232cfe9
unbreak express
mike-marcacci Jun 6, 2018
b03ce1d
add test for early return
mike-marcacci Jun 7, 2018
b8a0798
add better test failure message
mike-marcacci Jun 7, 2018
10f0839
Merge branch 'fix/prevent-crashes' into fix/finish-request-before-res…
mike-marcacci Jun 7, 2018
d473973
Merge branch 'fix/prevent-crashes' into fix/finish-request-before-res…
mike-marcacci Jun 8, 2018
6b5d625
Merge branch 'master' into fix/prevent-crashes
mike-marcacci Jun 8, 2018
7599006
add tests for abort, refactor to destroy streams
mike-marcacci Jun 8, 2018
1aa7e67
make sure not to destroy an upload stream twice
mike-marcacci Jun 8, 2018
2ba4713
remove .finally() for node v8 support
mike-marcacci Jun 8, 2018
e6c7c76
more clear fixture
mike-marcacci Jun 8, 2018
c9a38da
more clear fixture
mike-marcacci Jun 8, 2018
986efa6
add tests to ensure unhandled stream doesnt crash
mike-marcacci Jun 9, 2018
957fd18
Merge branch 'fix/prevent-crashes' into fix/finish-request-before-res…
mike-marcacci Jun 9, 2018
b3d2a63
Make requested fixes.
mike-marcacci Jun 9, 2018
d1b923b
fix build:prettier script
mike-marcacci Jun 9, 2018
50d206a
Add tests for unconsumed uploads
mike-marcacci Jun 9, 2018
2e3a1be
Add better test for unconsumed upload.
mike-marcacci Jun 9, 2018
44a0e14
Add UploadBuffer stream
mike-marcacci Jun 9, 2018
8348279
revert some small changes
mike-marcacci Jun 9, 2018
07009b6
add unconsumed uploads test for express
mike-marcacci Jun 9, 2018
6f7d10f
pass original error on parser error event
mike-marcacci Jun 9, 2018
662ce40
update readme and changelog
mike-marcacci Jun 9, 2018
32327ba
rename UploadBuffer to UploadStream
mike-marcacci Jun 9, 2018
40dfe96
Use fs-capacator to buffer streams
mike-marcacci Jun 11, 2018
2c66e6b
add fs-capacitor as dependency
mike-marcacci Jun 11, 2018
5685ddb
rename body to operations
mike-marcacci Jun 18, 2018
b84661b
fix error caused by pretttier
mike-marcacci Jun 18, 2018
44fea2a
un-skip abort test
mike-marcacci Jun 18, 2018
095343e
add test scripts for mjs issue demonstration
mike-marcacci Jun 18, 2018
c71fcb1
upgrade fs-capacitor for better js/mjs interop
mike-marcacci Jun 18, 2018
53da40d
remove unnecessary scripts from package.json
mike-marcacci Jun 18, 2018
8b05eaf
fix babel issues
mike-marcacci Jun 19, 2018
b245e5f
dont skip abort test
mike-marcacci Jun 19, 2018
8f064e6
use a callback to remove breaking change
mike-marcacci Jun 19, 2018
1782688
more changes
mike-marcacci Jun 19, 2018
e5f49dc
Fix for all tests in node < 10
mike-marcacci Jul 5, 2018
0f02b12
Merge branch 'master' into fix/finish-request-before-responding
mike-marcacci Jul 5, 2018
d9161a1
prevent server crashes from invalid json
mike-marcacci Jul 5, 2018
7891c16
Restore 'Exceed max file size.' test
mike-marcacci Jul 5, 2018
19cfcd8
reinstate 'Deduped files.' test
mike-marcacci Jul 5, 2018
823b979
use eos to detect stream finish
mike-marcacci Jul 5, 2018
5b9a5a6
Revert "use eos to detect stream finish"
mike-marcacci Jul 5, 2018
8180d50
Address EPIPE test error in old node versions
mike-marcacci Jul 5, 2018
8dc5c1d
Revert "Address EPIPE test error in old node versions"
mike-marcacci Jul 5, 2018
eefb6dc
Unpipe immediately on filesLimit
mike-marcacci Jul 5, 2018
2b1fc86
make sure to wait in error scenarios
mike-marcacci Jul 5, 2018
47a85a0
dont finish until request has ended
mike-marcacci Jul 5, 2018
cbf8c7d
make sure to unpipe on error as well
mike-marcacci Jul 5, 2018
ba8f901
Better treatment of MaxFileSizeUploadError
mike-marcacci Jul 6, 2018
8c3e51a
test actual file contents
mike-marcacci Jul 6, 2018
4f7e2fd
fix inadvertant changes
mike-marcacci Jul 6, 2018
5afbd4b
remove errorHandler config option
mike-marcacci Jul 6, 2018
04d56bc
fix typo
mike-marcacci Jul 6, 2018
531c6aa
Remove callback arg from processRequest
mike-marcacci Jul 6, 2018
77bafbc
remove unused code
mike-marcacci Jul 6, 2018
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@
"build:clean": "rm -r lib; mkdir lib",
"build:mjs": "BABEL_ESM=1 babel src -d lib --keep-file-extension",
"build:js": "babel src -d lib",
"build:prettier": "prettier 'lib/**/*.{mjs,js}' --write",
"build:prettier": "prettier 'src/**/*.{mjs,js}' --write && prettier '*.{json,md}' --write",
Copy link
Owner

Choose a reason for hiding this comment

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

Please undo all these script changes; for an explanation see 8928ce0.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Apologies! Will fix.

"lint:eslint": "eslint . --ext mjs,js",
"lint:prettier": "prettier '**/*.{json,md}' -l",
"lint:prettier": "prettier '*.{json,md}' -l",
Copy link
Owner

Choose a reason for hiding this comment

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

Ditto, undo.

"tap:mjs": "node --experimental-modules --no-warnings lib/test | tap-mocha-reporter spec",
"tap:js": "node lib/test | tap-mocha-reporter spec",
"watch": "watch 'npm test --silent' src --interval 1",
Expand Down
41 changes: 35 additions & 6 deletions src/middleware.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ class Upload {
this.done = true
Copy link
Owner

Choose a reason for hiding this comment

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

It looks like this is not used anywhere anymore?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yep, that's correct! Removed.

})

// Prevent a crash if the stream disconnects before the consumers’ error
// listeners can attach.
file.stream.on('error', () => {})

// Monkey patch busboy to emit an error when a file is too big.
file.stream.once('limit', () =>
file.stream.emit(
Expand Down Expand Up @@ -94,7 +98,7 @@ export const processRequest = (
operationsPath.set(path, map.get(fieldName).promise)
}

resolve(operations)
resolve({ operations, map })
Copy link
Owner

Choose a reason for hiding this comment

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

This is a breaking change to the API. A fair few people such as Apollo use processRequest directly.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That's a good point; I will change how this works.

}
}
})
Expand Down Expand Up @@ -138,6 +142,8 @@ export const processRequest = (
)
})

parser.on('error', () => null)

request.on('close', () => {
if (map)
for (const upload of map.values())
Expand All @@ -162,16 +168,39 @@ export const processRequest = (
})

export const apolloUploadKoa = options => async (ctx, next) => {
if (ctx.request.is('multipart/form-data'))
ctx.request.body = await processRequest(ctx.req, options)
await next()
if (!ctx.request.is('multipart/form-data')) return next()

// add uploads to the request
const { operations, map } = await processRequest(ctx.req, options)
ctx.request.body = operations

try {
ctx.respond = false
await next()
} finally {
await Promise.all(
[...map.values()].map(async upload => {
if (upload.done) return

await upload.promise
return new Promise(resolve => {
upload.file.stream.on('end', resolve)
upload.file.stream.on('error', resolve)
if (!upload.file.stream.readableFlowing) upload.file.stream.resume()
Copy link
Owner

Choose a reason for hiding this comment

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

This looks tricky, what is it for?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We don't want to interfere with any streams that are being consumed, but we do want to run through the rest. Using readableFlowing let's us know what state the stream is in.

})
})
)

ctx.respond = true
if (ctx.body) ctx.body = ctx.body
}
}

export const apolloUploadExpress = options => (request, response, next) => {
if (!request.is('multipart/form-data')) return next()
processRequest(request, options)
.then(body => {
request.body = body
.then(({ operations }) => {
request.body = operations
next()
})
.catch(error => {
Expand Down
61 changes: 61 additions & 0 deletions src/test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'fs'
import { Readable } from 'stream'
import t from 'tap'
import Koa from 'koa'
import express from 'express'
Expand Down Expand Up @@ -101,6 +102,66 @@ t.test('Single file.', async t => {
})
})

t.test('Early response.', async t => {
t.jobs = 1
Copy link
Owner

Choose a reason for hiding this comment

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

t.jobs is only necessary when there are multiple subtests. Parallelism is default for top level tests, but child tests a sync unless you set t.jobs > 1.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Great to know! (I'm new to tap.)


const testRequest = async (port, stream) => {
const body = new FormData()

body.append(
'operations',
JSON.stringify({
variables: {
file: null
}
})
)

body.append('map', JSON.stringify({ 1: ['variables.file'] }))
body.append(1, stream)

await fetch(`http://localhost:${port}`, { method: 'POST', body })
}

await t.test('Koa middleware.', async t => {
t.plan(1)

const data = fs.readFileSync(TEST_FILE_PATH)

var requestHasFinished = false
Copy link
Owner

Choose a reason for hiding this comment

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

Better to use let. You probably don't even need the = false.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sounds good; not to get side-tracked, but is there a benefit to let over var for top-level function-scoped variables, other than stylistic consistency?

const stream = new Readable()
stream.path = TEST_FILE_PATH
stream._read = () => {}
Copy link
Owner

Choose a reason for hiding this comment

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

What is this about? The _ appears to indicate a private method.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is the old, but still-supported way of implementing streams in node (for Streams 2, which is still the current implementation).

stream.on('end', () => {
requestHasFinished = true
})

const app = new Koa().use(apolloUploadKoa()).use(ctx => {
ctx.body = 'EARLY RETURN VALUE'
})
const port = await startServer(t, app)
const promise = testRequest(port, stream).then(
() => {
t.equals(
requestHasFinished,
true,
'The server should not respond before the request has finished'
)
},
err => {
throw err
Copy link
Owner

Choose a reason for hiding this comment

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

Would this promise block would be more elegant as an await with no surrounding try/catch, since we throw on error anyway?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Totally!

}
)

setTimeout(() => {
stream.push(data)
stream.push(null)
}, 10)

return promise
Copy link
Owner

Choose a reason for hiding this comment

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

I prefer await to return here, as it makes it clear the value isn't used back up the chain somehow.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sounds good!

})
})

t.test('Deduped files.', async t => {
t.jobs = 2

Expand Down